学习目标
学完本章,你应该能够:
- 用自己的话说清 Operator 是什么、解决什么问题——本质是把"运维人员的操作经验"代码化,让复杂有状态应用能自动化部署、升级、备份、恢复。
- 讲清 CRD 与 Controller 的分工,以及"声明式控制循环 + 水平触发"为什么是 Operator 的核心心智。
- 写出一个带 finalizer、状态更新、错误分级 的完整
Reconcile函数,理解幂等性和 OwnerReference 的作用。 - 看懂并写出一份生产级 CRD:OpenAPI schema 校验、
status子资源、additionalPrinterColumns、observedGeneration。 - 用 kubebuilder 从零搭一个 Operator 项目骨架,并把它装到测试集群里跑起来。
前置知识:
- 基本 K8s 概念:Pod、Deployment、StatefulSet、etcd 是什么。
- 会写一点 Go(struct、interface、方法),不需要很熟。
- 知道 YAML 的基本格式(缩进、键值对)。
- 大致知道 controller-runtime / kubebuilder 是干嘛的(不熟也行,本章会带过)。
本章你会动手做的事:
- 用
kubebuilder init起一个空的 Operator 项目骨架,感受目录结构。 - 给示例
AITaskCRD 的status.phase加一个新枚举值,重新make manifests看生成的 YAML 变化。 - 故意在删除逻辑里不加 finalizer,删一个 CR,观察外部资源没被清理的"坑"。
先建立直觉:Operator 是"机器人 SRE"
类比:Operator 就像一个 7×24 小时不睡觉的"机器人 SRE(网站可靠性工程师)"。传统上,部署一套高可用 PostgreSQL 要靠真人 DBA 手动建实例、配主从复制、做备份、故障时手动把从库提升成主库。Operator 把这位 DBA 脑子里的"操作手册"写成代码,跑在 K8s 里——你只声明"我要一个 3 副本的 PG 集群",它自己把活全干了,主库挂了它还自己选从库顶上。一句话:把人肉运维经验沉淀成声明式配置。
什么是 Operator
Operator 这玩意儿说白了就是 Kubernetes 的扩展机制,专门把特定领域知识(Domain Knowledge)编码进软件里,让复杂的有状态应用能自动化部署、升级、备份和恢复。CoreOS 在 2016 年提出这个概念,核心思想一句话:
将运维人员的操作经验转化为代码,并以自定义控制器(Controller)的形式运行在 Kubernetes 中。
常见的 Operator 实现有 etcd Operator、Prometheus Operator、MySQL Operator、Redis Operator 这些。
光说概念太空,我拿 PostgreSQL Operator 举个实在例子。你想想,传统方式部署一个高可用 PG 集群要干多少活?建 StatefulSet 管 Pod、配 PVC 存数据、搞主从复制、配 pg_hba.conf、做流复制、定时备份 WAL 日志、故障时手动 failover 把从库提升为主库……这些步骤全是 SRE 手动操作的"领域知识"。
PostgreSQL Operator(比如 CrunchyData 的 PGO)把这些全封装进去了。你只需要声明一个 PostgresCluster CR:
apiVersion: postgres-operator.crunchydata.com/v1beta1
kind: PostgresCluster
metadata:
name: hippo
spec:
image: registry.developers.crunchydata.com/crunchydata/crunchy-postgres:ubi8-15.4-0
postgresVersion: 15
instances:
- name: instance1
replicas: 3 # 一个主两个从,PGO 自己管复制关系
dataVolumeClaimSpec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 1Gi
backups:
pgbackrest:
repos:
- name: repo1
schedules:
full: "0 1 * * 0" # 每周日凌晨全量备份
incremental: "0 1 * * 1-6" # 周一到周六增量
然后 PGO 就自己起 3 个 PG 实例、配好流复制、起 pgBackRest 做备份。主库挂了?Operator 自己挑一个从库 promote 成主库,再把其他从库指过去。你啥都不用管。
这就是 Operator 的价值——把"PG DBA 的脑子"装进了控制器。Prometheus Operator 管 alertmanager 配置、etcd Operator 管集群扩缩容和快照,都是同一个套路:把人肉运维步骤变成 reconcile 里的代码。
Operator 设计原理
Operator 通常由两部分组成:
- CRD(Custom Resource Definition):定义自定义资源的数据结构,相当于扩展了 Kubernetes API。
- Controller:监听 CR 的变化,执行 reconcile 逻辑,确保实际状态与期望状态一致。
建立直觉:CRD 与 Controller 的分工
类比:把 K8s 想成一家公司。
CRD是 HR 发布的"新岗位说明书"——规定了这个岗位叫什么(Kind)、归哪个部门(Group)、要填哪些简历字段(Spec)、汇报内容长啥样(Status)。Controller是坐在这个岗位上的"员工"——他盯着所有填了这张表的人(CR 实例),不停核对"简历上写的要求"和"实际执行情况"对不对得上,对不上就动手调整,直到一致。员工随时可能被换(pod 重启),但只要岗位说明书在,换个员工照样干。
工作模式遵循 Kubernetes 的声明式控制循环:
用户声明 CR -> API Server 存储 -> Controller Watch 到变化
-> Reconcile 调谐 -> 创建/更新/删除关联资源 -> 更新 CR 状态
下面把这条控制循环画成图,建议记住这个闭环——后面所有 reconcile 代码都是在这个环里转:
flowchart LR
U[用户 kubectl apply CR] --> API[API Server 写入 etcd]
API --> W[Controller Watch 到变化]
W --> R[Reconcile 调谐]
R --> S[创建 更新 删除 关联子资源]
S --> ST[更新 CR 的 status]
ST --> W水平触发 vs 边缘触发:reconcile 的核心心智
这里有个坑我必须重点讲,因为很多人写了几个月 controller 都没真正想明白——为啥 reconcile 要设计成"水平触发"而不是"边缘触发"?
先说边缘触发(edge-triggered),就是"事件来了我才处理一次,处理完就完事"。比如某个 Pod 状态从 Running 变成 Error,你收到事件去处理,然后网络抖了一下你的处理逻辑挂了,这个事件就永远丢了,没人再管这个 Pod。
水平触发(level-triggered)不一样,它看的是"当前状态"而不是"状态变化"。每次 reconcile 进来,Controller 看的是"现在 CR 期望 3 副本,实际是 2 副本,差一个",就补一个。不管你中间崩了几次,只要下次 reconcile 还在跑,最终都会收敛到期望状态。
打个比方:边缘触发像你妈喊你吃饭,你没听见就饿着;水平触发像冰箱上贴了张菜单,你每次路过都对照一下还缺啥。
边缘触发(event-driven) 水平触发(level-triggered)
───────────────────────────── ─────────────────────────────
事件 -> 处理一次 -> 丢弃 每次 reconcile 看当前状态
丢事件 = 永久失联 崩了重启继续看状态
逻辑复杂:要自己记"处理到哪了" 逻辑简单:幂等地收敛状态
类比:reconcile 像月底对账。边缘触发是"每来一笔流水就处理一次,处理完就把纸条扔了"——某笔流水处理时系统崩了,这笔账就永远对不上。水平触发是"每次都拿出账本,对照’应有余额’和’实际余额’,差多少补多少"——崩了重启接着对账,最终一定平。这就是 K8s 偏爱水平触发的根本原因:不依赖事件、不怕丢。
水平触发为什么"崩了重启还能收敛",看这张图最直观——它就是一个不断拿实际状态去对齐期望状态的循环:
flowchart TD
A[Controller 被触发 reconcile] --> B[读取 CR 当前实际状态]
B --> C[读取期望状态 spec]
C --> D{实际状态 == 期望状态?}
D -->|是| E[什么都不做 直接返回]
D -->|否| F[创建 更新 删除 子资源 收敛状态]
F --> G[更新 CR status]
G --> AKubernetes 整个设计都偏爱水平触发。reconcile 函数你随便 kill -9,重启后从 last reconcile 重新看一遍状态,照样能收敛。写代码时就一个原则:别假设上次跑过啥,每次都从头看当前状态决定干啥。
我个人觉得,理解了这点,你写 controller 就不会再去纠结"我这个事件漏处理了怎么办"——根本不需要处理事件,你处理的是状态。
Operator 生命周期
一个 Operator 的完整生命周期大致这几个阶段:
| 阶段 | 说明 |
|---|---|
| 安装 | 通过 YAML、Helm 或 OLM 部署 Operator |
| 部署 | 用户创建 CR,Operator 根据 CR 创建应用实例 |
| 升级 | 修改 CR 或更新 Operator 镜像版本 |
| 扩缩容 | 调整 CR 中的副本数或资源规格 |
| 备份恢复 | 通过自定义逻辑实现数据保护 |
| 卸载 | 删除 CR,Operator 清理关联资源 |
完整状态机
光看表格还是抽象,我把 CR 的状态流转画一下。一个 CR 从创建到销毁,大致经历这么几个 phase:
┌──────────────┐
kubectl apply ─> │ Pending │ CR 刚创建,controller 还没 reconcile
└──────┬───────┘
│ reconcile 开始
v
┌──────────────┐
│ Creating │ 正在创建子资源(Pod/PVC/Service)
└──────┬───────┘
│ 子资源就绪
v
┌──────────────┐
┌──────────│ Running │ <─────┐ reconcile 自愈
│ └──────┬───────┘ │
│ │ spec 变更 │
│ v │
│ ┌──────────────┐ │
│ │ Updating │ ──────┘
│ └──────────────┘
│
│ 故障/删除
v
┌──────────────┐ ┌──────────────┐
│ Failed │ <───── │ Deleting │ finalizer 清理外部资源
└──────┬───────┘ └──────┬───────┘
│ 重试成功 │ 清理完
v v
回到 Running CR 从 etcd 删除
类比:这张图就是 CR 的"一生"。从
Pending(刚出生没人管)到Creating(正在搭基础设施),到Running(稳定运行),用户改配置了进Updating(滚动更新),出故障进Failed(但 reconcile 会自愈拉回Running),被删除时进Deleting(靠 finalizer 先把外部资源擦干净,才真正从 etcd 消失)。每个箭头都是一次 reconcile 的决策结果。
用 Mermaid 把这个状态机再画一遍,方便你对照记忆(注意 Deleting 必须等 finalizer 清理完才能回到起点之外):
stateDiagram-v2
[*] --> Pending
Pending --> Creating: reconcile 开始
Creating --> Running: 子资源就绪
Running --> Updating: 用户改 spec
Updating --> Running: 滚动更新完成
Running --> Failed: 发生故障
Failed --> Running: 自愈成功
Running --> Deleting: 用户删除 CR
Deleting --> [*]: finalizer 清理完外部资源几个关键点说下:
- Pending → Creating:controller 第一次拿到 CR,初始化子资源。这步可能因为镜像拉不下来卡住,所以 status 要写清楚原因。
- Running → Updating:用户改 spec(比如副本数 3 改 5),controller 看到实际跟期望对不上,进入 Updating。这里有个坑:更新过程要保证滚动更新,别一刀切全删。
- Failed → Running:自愈靠 reconcile 循环,每次进来都检查是否真的 failed,恢复后自动回 Running。
- Deleting:靠 finalizer 实现。没有 finalizer 的话,你删 CR,etcd 直接删掉,controller 根本来不及清理外部资源(比如 PG 集群在外部 RDS 上的实例)。
CRD 核心开发技术
CRD 通过 YAML 定义自定义资源的 Group、Version、Kind 和 Spec/Status 结构。说白了就是给 Kubernetes API 加一张新表。
建立直觉:CRD 是给 K8s API 加一张新表
类比:K8s 内置资源(Pod、Deployment)本质就是 API Server 后面 etcd 里的一张张"表"。
CRD让你自己再建一张新表——定义表名(Group/Kind)、字段结构(Spec/Status)、字段类型校验(schema 约束)。建好表后,你就能kubectl apply往里插"一行行记录"(CR 实例),而 Controller 就是盯着这张表、负责让记录里声明的状态变成现实的后台进程。前面那张PostgresCluster表就是 CrunchyData 定义的。
完整 CRD 示例(含 schema 校验、status 子资源、printer columns)
下面这个 CRD 我写得完整点,把实际项目里该有的东西都加上:
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: aitasks.aiops.example.com
spec:
group: aiops.example.com
scope: Namespaced
names:
plural: aitasks
singular: aitask
kind: AITask
shortNames:
- at
versions:
- name: v1
served: true
storage: true
# kubectl get 时显示的额外列,省得每次都要 -o yaml
additionalPrinterColumns:
- name: Target
type: string
jsonPath: .spec.targetDeployment
- name: Threshold
type: number
jsonPath: .spec.threshold
- name: Action
type: string
jsonPath: .spec.action
- name: Phase
type: string
jsonPath: .status.phase
- name: Age
type: date
jsonPath: .metadata.creationTimestamp
schema:
openAPIV3Schema:
type: object
# 顶层必填 spec,防止空 CR 进来
required: ["spec"]
properties:
spec:
type: object
required: ["targetDeployment", "threshold", "action"]
properties:
targetDeployment:
type: string
# minLength 防止空字符串,踩过坑:空值会让 reconcile 里 Get 报 NotFound
minLength: 1
maxLength: 253
namespace:
type: string
default: default
threshold:
type: number
# 业务上阈值不该负数,用 minimum 兜底
minimum: 0
action:
type: string
# enum 限制取值,挡住乱填的 CR
enum:
- restart_pod
- scale_up
- scale_down
- cordon_node
- noop
intervalSeconds:
type: integer
minimum: 10
maximum: 3600
default: 60
llmEndpoint:
type: string
# 用 format 做基本格式校验
format: uri
status:
type: object
properties:
phase:
type: string
enum: [Pending, Monitoring, Healing, Healed, Failed]
lastDecision:
type: string
lastExecutionTime:
type: string
format: date-time
observedGeneration:
type: integer
format: int64
conditions:
type: array
items:
type: object
required: ["type", "status"]
properties:
type:
type: string
status:
type: string
enum: ["True", "False", "Unknown"]
reason:
type: string
message:
type: string
lastTransitionTime:
type: string
format: date-time
# status 子资源独立更新,避免和 spec 抢冲突
subresources:
status: {}
几个容易忽略的点我说下:
subresources.status: {}:这个不写的话,更新 status 要带整个 spec 一起 PUT,冲突概率高得离谱。开了之后用/status子路径单独更新,spec 和 status 各走各的。additionalPrinterColumns:不写的话kubectl get at只能看到 NAME 和 AGE,啥信息都没有。加上之后直接能看 phase、target,排查问题快很多。observedGeneration:status 里加这个字段,写metadata.generation的值。这样能判断"controller 有没有处理过最新一次 spec 变更"——如果 generation 比 observedGeneration 大,说明 controller 还没跟上。- enum + minimum:能在 API Server 层挡住一堆垃圾输入,别等到 reconcile 里才发现 spec 是负数。
⚠️ 新手必踩的坑:漏了
observedGeneration导致"改了没生效"假象。你改了 CR 的 spec(比如把副本数从 3 改成 5),但 Controller 因为某种原因卡住没处理。如果 status 里没记observedGeneration,你kubectl get看到 phase 还是旧值,会以为"改了不生效",其实是 controller 还没跟上。加上这个字段,一眼就能对比metadata.generation和status.observedGeneration是否相等,判断 controller 是否处理过最新变更。
踩坑提示:CRD schema 是 OpenAPI v3 子集,不支持 oneOf 在某些老版本里行为怪异,复杂校验还是放 controller 里做更稳。还有 x-kubernetes-list-type: map 这种高级特性在老集群(1.18 之前)不认,升级集群前别用。
Controller 与 Reconcile
Controller 的核心就是 reconcile 函数。每次资源变化,Controller 把资源的 Namespace/Name 丢进工作队列,worker 慢慢消费,调 reconcile。
建立直觉:reconcile 是"对账"不是"响应事件"
类比:很多人初学会把 reconcile 写成"收到事件→执行一次操作"。这是边缘触发的旧习惯,在 K8s 里是错的。正确的心智是:reconcile 是对账函数——它不关心"刚才发生了什么事件",只关心"现在实际状态和期望状态差在哪",然后让实际对齐期望。因为是对账,所以幂等:查 100 遍账,结果一样;因为是对账,所以不怕崩:崩了重启再查一遍账,照样平。带着这个心智看下面这段代码,所有
return ctrl.Result{...}都是在说"这一轮先到这,下一轮接着对账"。
reconcile 主流程其实就是上一节那张"实际==期望?“循环的代码化,先上一张流程图帮你建立整体路径感,再读代码就不晕了:
flowchart TD
Q[收到 Request Namespace/Name] --> G[Get CR]
G -->|NotFound| X[直接返回 忽略这次事件]
G -->|存在| D{DeletionTimestamp 非零?}
D -->|是| DEL[reconcileDelete 清理外部资源 去掉 finalizer]
D -->|否| F{已有 finalizer?}
F -->|否| ADD[加上 finalizer 更新 返回 requeue]
F -->|是| MAIN[主逻辑 检查目标 执行业务]
MAIN --> UP[更新 status 并同步 observedGeneration]
UP --> RQ[RequeueAfter 周期监控]完整 kubebuilder 风格 reconcile
下面这段代码是实际项目里能跑的完整 reconcile,带 finalizer、状态更新、错误重试,我逐段加注释:
package controllers
import (
"context"
"fmt"
"time"
appsv1 "k8s.io/api/apps/v1"
"k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/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"
aiopsv1 "github.com/example/aiops-operator/api/v1"
)
const (
// finalizer 标识,删除时用来触发清理逻辑
aiTaskFinalizer = "aiops.example.com/finalizer"
// 默认 requeue 间隔,监控类任务用
defaultRequeue = 60 * time.Second
)
type AITaskReconciler struct {
client.Client
Scheme *runtime.Scheme
}
func (r *AITaskReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
log := log.FromContext(ctx)
// 1. 拿 CR。NotFound 不报错,可能是被删了,直接返回
var task aiopsv1.AITask
if err := r.Get(ctx, req.NamespacedName, &task); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
// 2. 处理删除:DeletionTimestamp 不为零说明在删,走 finalizer 清理
if !task.DeletionTimestamp.IsZero() {
return r.reconcileDelete(ctx, &task)
}
// 3. 确保 finalizer 存在。没加就加上,这步必须在任何外部副作用之前
if !controllerutil.ContainsFinalizer(&task, aiTaskFinalizer) {
controllerutil.AddFinalizer(&task, aiTaskFinalizer)
if err := r.Update(ctx, &task); err != nil {
// 冲突就重试,别硬刚
return ctrl.Result{Requeue: true}, nil
}
return ctrl.Result{Requeue: true}, nil
}
// 4. 主逻辑:检查目标 Deployment 是否存在
var dep appsv1.Deployment
if err := r.Get(ctx, types.NamespacedName{
Namespace: task.Spec.Namespace,
Name: task.Spec.TargetDeployment,
}, &dep); err != nil {
if errors.IsNotFound(err) {
// 目标不存在,标记 Failed,但不重试(用户得自己改 spec)
_ = r.updateStatus(ctx, &task, "Failed", "target deployment not found", "")
return ctrl.Result{RequeueAfter: defaultRequeue}, nil
}
// 其他错误(API Server 抖动)短重试
return ctrl.Result{}, err
}
// 5. 执行业务逻辑(这里简化为占位,实际项目里调 Prometheus + LLM)
action, err := r.evaluateAndAct(ctx, &task, &dep)
if err != nil {
log.Error(err, "evaluate action failed")
// 业务错误:更新 status 为 Failed,但短 requeue 重试,避免永久卡死
_ = r.updateStatus(ctx, &task, "Failed", err.Error(), "")
return ctrl.Result{RequeueAfter: 30 * time.Second}, nil
}
// 6. 更新 status。注意 observedGeneration 要同步
phase := "Monitoring"
if action != "" && action != "noop" {
phase = "Healed"
}
if err := r.updateStatus(ctx, &task, phase, "", action); err != nil {
return ctrl.Result{}, err
}
// 7. 周期性 reconcile,持续监控
interval := time.Duration(task.Spec.IntervalSeconds) * time.Second
if interval == 0 {
interval = defaultRequeue
}
return ctrl.Result{RequeueAfter: interval}, nil
}
// reconcileDelete 清理外部资源。finalizer 模式的关键:清理完才能去掉 finalizer
func (r *AITaskReconciler) reconcileDelete(ctx context.Context, task *aiopsv1.AITask) (ctrl.Result, error) {
log := log.FromContext(ctx)
// 这里清理外部副作用,比如撤销 cordon、删外部 webhook 注册等
// 示例:如果 action 是 cordon_node,要记得 uncordon
if task.Spec.Action == "cordon_node" {
if err := r.cleanupCordon(ctx, task); err != nil {
log.Error(err, "cleanup cordon failed, will retry")
// 清理失败绝不能去掉 finalizer,否则资源泄漏
return ctrl.Result{RequeueAfter: 10 * time.Second}, nil
}
}
controllerutil.RemoveFinalizer(task, aiTaskFinalizer)
if err := r.Update(ctx, task); err != nil {
return ctrl.Result{Requeue: true}, nil
}
return ctrl.Result{}, nil
}
// updateStatus 统一更新 status,处理冲突
func (r *AITaskReconciler) updateStatus(ctx context.Context, task *aiopsv1.AITask, phase, reason, decision string) error {
task.Status.Phase = phase
task.Status.LastDecision = decision
task.Status.LastExecutionTime = metav1.Now().Format(time.RFC3339)
task.Status.ObservedGeneration = task.Generation
// status 更新用 Status().Update 更稳,避免覆盖 spec
return r.Status().Update(ctx, task)
}
// evaluateAndAct 业务逻辑占位
func (r *AITaskReconciler) evaluateAndAct(ctx context.Context, task *aiopsv1.AITask, dep *appsv1.Deployment) (string, error) {
// 实际项目:查 Prometheus -> 判断阈值 -> 调 LLM -> 执行
// 这里只返回 noop,下一篇实战里补全
return "noop", nil
}
func (r *AITaskReconciler) cleanupCordon(ctx context.Context, task *aiopsv1.AITask) error {
// 实现略,记得把之前 cordon 过的节点 uncordon
return nil
}
// SetupWithManager 注册 controller 要 watch 的资源
func (r *AITaskReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&aiopsv1.AITask{}).
// Owns 表示管理子资源(Deployment 变了也触发 reconcile),需要 OwnerReference
Owns(&appsv1.Deployment{}).
Complete(r)
}
// 防止 import 未使用
var _ = fmt.Sprintf
reconcile 函数有几条铁律我反复强调:
- 幂等性:同一条事件处理 100 遍结果一致。别在里面写"如果没执行过就执行"这种逻辑,靠 status 记录状态判断。
- 快速返回:别在 reconcile 里 sleep 或调慢接口。慢操作堵 workqueue,反正有 RequeueAfter。
- 错误分级:可重试错误(API 抖动)返回
Requeue,业务错误(目标不存在)更新 status 后 RequeueAfter,终态错误不重试。 - OwnerReference:创建子资源时设上,级联删除自动搞定,省得写一堆清理代码。
⚠️ 新手必踩的坑:在 reconcile 里写了"只执行一次"的逻辑。比如"如果这个 Deployment 还没创建过,就创建它”——听起来没问题,但水平触发下 reconcile 会被调用很多次(事件、周期 requeue、controller 重启)。正确做法是永远基于当前状态决定动作:"
Get不到就创建,拿到了就对比 spec 决定更不更新"。靠 status 里记录的observedGeneration/phase来判断"该不该做",而不是靠"我刚才做没做"。违反幂等性,轻则重复创建资源报 AlreadyExists,重则状态错乱。
踩坑提示:r.Status().Update 在没开 CRD 的 subresources.status 时会报错"the body of the request was in an unknown state",这个报错信息巨误导,实际就是 CRD 里忘了配 status 子资源。还有个坑:Update 失败(冲突)时别用 Requeue: true 死循环重试,加个退避,不然 API Server 被你打爆。
Operator 开发工具链
常用的 Operator 开发工具就那么几个:
- kubebuilder:基于 CRD 和 controller-runtime 的项目脚手架,功能完整,Kubernetes 官方在推。
- operator-sdk:RedHat 主导,支持 Go、Ansible、Helm 三种 Operator 类型。
- controller-runtime:底层库,kubebuilder 和 operator-sdk 都基于它。
kubebuilder vs operator-sdk 怎么选
这俩我都被坑过,直接上对比:
kubebuilder operator-sdk
───────────── ─────────────────── ──────────────────────
主导方 Kubernetes SIG RedHat
语言支持 Go Go / Ansible / Helm
脚手架风格 纯 Go,目录清晰 多语言,配置略繁
与 OLM 集成 弱 强(OLM 是 RedHat 的)
学习曲线 平缓,文档好 稍陡,但 Ansible Operator 对运维友好
生成代码质量 干净 Go 模式跟 kubebuilder 差不多
社区活跃度 高 高
我个人觉得:纯 Go 写 controller 直接上 kubebuilder,文档和示例都全;如果团队 SRE 不懂 Go、只会 Ansible,那 operator-sdk 的 Ansible Operator 模式能让他们直接用 playbook 写 controller,门槛低很多。Helm Operator 适合把已有 Helm Chart 包成 Operator,但定制能力弱,复杂场景别用。
kubebuilder init 命令完整流程
从零搭一个项目,命令就这几条:
# 1. 初始化项目骨架。--domain 决定 API group 后缀
kubebuilder init --domain aiops.example.com \
--repo github.com/example/aiops-operator \
--license apache2
# 2. 创建一个 API(CRD + Controller 一起生成)
# --group/--version/--kind 决定 CR 的 GVK
kubebuilder create api --group aiops --version v1 --kind AITask
# 3. 交互式会问 "Create Resource" 和 "Create Controller",都选 y
# 选 n 的话只生成 CRD 不生成 controller
# 4. 改 api/v1/aitask_types.go 里的 Spec/Status 结构体
# 然后重新生成 deepcopy 和 CRD YAML
make manifests
make generate
# 5. 本地装 CRD 跑起来(需要 kubeconfig 指向测试集群)
make install
make run
# 6. 构建、推送镜像、部署到集群
make docker-build docker-push IMG=registry.example.com/aiops-operator:v0.1
make deploy IMG=registry.example.com/aiops-operator:v0.1
踩坑提示:kubebuilder create api 之后,如果改了 _types.go 里的字段,必须重新跑 make manifests && make generate,不然 CRD YAML 和 DeepCopy 方法是旧的,运行时会出现"字段明明写了但 controller 拿不到"的玄学问题。还有,make run 默认会用 ~/.kube/config,跑生产集群前确认 kubeconfig 指向对的环境,我见过有人本地 make run 直接连上生产把 CRD 装上去的,差点背锅。
基础 Operator 项目结构
kubebuilder 生成的项目结构大概长这样:
aiops-operator/
├── api/
│ └── v1/
│ ├── aitask_types.go # CR 类型定义(Spec/Status 结构体)
│ ├── groupversion_info.go # GroupVersion 注册
│ └── zz_generated.deepcopy.go # 自动生成的 DeepCopy 方法,别手改
├── cmd/
│ └── main.go # 入口(新版 kubebuilder 放 cmd 下)
├── config/
│ ├── crd/
│ │ ├── bases/ # 生成的 CRD YAML
│ │ └── kustomization.yaml
│ ├── default/ # 默认 kustomize 配置
│ ├── manager/ # Operator Deployment YAML
│ ├── prometheus/ # ServiceMonitor(监控用)
│ ├── rbac/ # ClusterRole/Binding
│ └── samples/ # 示例 CR
├── controllers/
│ ├── aitask_controller.go # 控制器逻辑(reconcile 在这)
│ └── suite_test.go # envtest 集成测试
├── internal/
│ └── llm/ # 自定义的内部包(LLM 客户端等)
├── Dockerfile
├── Makefile
├── go.mod
├── go.sum
└── PROJECT # kubebuilder 元数据
main.go 启动代码
入口文件干的事其实就三件:注册 Scheme、启动 Manager、把 Reconciler 挂上去。我贴一个完整的:
package main
import (
"flag"
"os"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
"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"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/healthz"
"sigs.k8s.io/controller-runtime/pkg/log/zap"
metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"
aiopsv1 "github.com/example/aiops-operator/api/v1"
"github.com/example/aiops-operator/controllers"
)
var (
scheme = runtime.NewScheme()
setupLog = ctrl.Log.WithName("setup")
)
func init() {
// 注册所有要操作的内置类型,不然 client.Get 拿不到对象
utilruntime.Must(clientgoscheme.AddToScheme(scheme))
utilruntime.Must(appsv1.AddToScheme(scheme))
utilruntime.Must(corev1.AddToScheme(scheme))
// 注册自定义 CR 类型
utilruntime.Must(aiopsv1.AddToScheme(scheme))
}
func main() {
var metricsAddr string
var probeAddr string
var enableLeaderElection bool
flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "Metrics 地址")
flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "Probe 地址")
flag.BoolVar(&enableLeaderElection, "leader-elect", false,
"多副本时开 leader election,避免重复 reconcile")
flag.Parse()
// 日志用 zap,生产环境别用默认的
opts := zap.Options{
Development: false,
}
ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts)))
// 启动 manager,它管着 cache、client、metrics 这些公共组件
mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
Scheme: scheme,
Metrics: metricsserver.Options{BindAddress: metricsAddr},
HealthProbeBindAddress: probeAddr,
LeaderElection: enableLeaderElection,
LeaderElectionID: "aiops-operator.example.com",
})
if err != nil {
setupLog.Error(err, "unable to start manager")
os.Exit(1)
}
// 把 Reconciler 注册到 manager,并指定它要 watch 哪些资源
if err = (&controllers.AITaskReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
}).SetupWithManager(mgr); err != nil {
setupLog.Error(err, "unable to create controller", "controller", "AITask")
os.Exit(1)
}
// 健康检查 + 就绪检查,给 kubelet 探针用
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)
}
}
几个地方我特别说下为啥这么写:
init()里注册 Scheme:client-go 的 client 是泛型的,必须告诉它"我认识哪些 GVK"。漏注册某个类型,r.Get会直接报"no kind is registered for the type"。LeaderElection:Operator 部署多副本时必须开,不然两个 controller 同时 reconcile 同一个 CR,状态乱套。开了之后用 lease 抢锁,同一时刻只有一个生效。SetupSignalHandler:优雅退出,收到 SIGTERM 时先停 controller、flush 缓存再退出,避免 workqueue 里的事件丢失。- healthz/readyz:Deployment 的 livenessProbe/readinessProbe 指向这俩,不然 Pod 起不来 controller 还被当健康。
类比:main.go 的启动流程,就像一家分公司开张——先在
init()里把"公司通讯录"(Scheme)备齐,谁都认识;然后NewManager把"办公场地和后勤"(cache/client/metrics)租好;SetupWithManager把"业务负责人"(Reconciler)安排上岗、告诉他盯哪些业务;AddHealthzCheck给大楼装好消防和安检;最后Start正式开门营业。下面这张图就是这条启动链:
flowchart TD
I[init 注册 Scheme
内置类型 + 自定义 CR] --> M[NewManager 创建 Manager
管 cache client metrics]
M --> S[SetupWithManager 注册 Reconciler
For + Owns]
S --> H[AddHealthzCheck / AddReadyzCheck]
H --> ST[SetupSignalHandler 启动 Manager]踩坑提示:metrics-bind-address 默认是 :8080,跟 Prometheus Operator 的 ServiceMonitor 端口对齐。如果你集群里别的服务占了 8080,启动直接挂,记得改。另外 LeaderElectionID 必须全集群唯一,两个 operator 用同一个 ID 会互相抢锁,表现为 controller 隔几秒就重启,日志一堆 leader election 失败。
总结
Operator 是 Kubernetes 平台工程的核心能力。CRD 扩展 API、Controller 跑 reconcile 把状态收敛到期望,这套模式能把任意复杂的运维场景沉淀成声明式配置。
说实话我刚学的时候觉得 reconcile 这套"水平触发"设计特别绕,但写多了就发现它太省心了——你不用操心事件丢了、controller 崩了、网络抖了,每次进来从头看状态决定干啥就行。下一篇笔记会在这基础上接 LLM,做一个能自己判断故障、自己修复的 AIOps Operator。
自测题与动手练习
自测题(合上书能答出来,才算懂):
- Operator 的核心思想一句话是什么?它把什么"代码化"了?请举一个 etcd / Prometheus / MySQL Operator 之外的有状态应用例子。
- 水平触发(level-triggered)和边缘触发(edge-triggered)的本质区别是什么?为什么 Kubernetes 整个设计都偏爱水平触发?
- CRD 里
subresources.status: {}不写会怎样?observedGeneration字段是拿来干嘛的? - reconcile 函数有哪几条铁律(至少说出幂等、快速返回、错误分级、OwnerReference 四条)?每条违反分别会有什么后果?
- 为什么删除 CR 必须靠 finalizer 才能清理外部资源?如果一个 CR 没有 finalizer,你
kubectl delete之后会发生什么?
动手练习(建议真做一遍):
- 用
kubebuilder init起一个空项目,再kubebuilder create api --group aiops --version v1 --kind AITask生成骨架,跑make manifests看生成的 CRD YAML 长什么样。 - 给
AITaskCRD 的status.phase加一个新枚举值(比如Scaling),改完重新make manifests,再用kubectl explain aitask.status.phase验证枚举生效。 - 故意在
reconcileDelete里注释掉controllerutil.RemoveFinalizer(...),apply 一个 CR 再kubectl delete,观察 CR 一直卡在Terminating、且外部资源没被清理的现象,理解 finalizer 的作用。
本章小结
- Operator = CRD(扩展 API / 加一张新表)+ Controller(对账进程),核心是把运维领域知识代码化,让复杂有状态应用自动化。
- 设计灵魂是声明式控制循环 + 水平触发:reconcile 看的是"当前状态",不是"刚才的事件",所以崩了重启照样收敛、逻辑天然幂等。
- CR 有完整生命周期与状态机(Pending → Creating → Running ↔ Updating / Failed → Deleting),
Deleting必须等 finalizer 把外部资源清理完,才能真正从 etcd 消失。 - 生产级 CRD 要配齐 schema 校验、status 子资源、additionalPrinterColumns、observedGeneration,把垃圾输入挡在 API Server 层。
- reconcile 铁律:幂等、快速返回、错误分级、OwnerReference;删除靠 finalizer,更新 status 用
Status().Update。 - kubebuilder 一条龙:
init→create api→ 改_types.go→make manifests && make generate→make install→make run,改了类型定义必须重新生成。
下一篇笔记会在这套 Operator 骨架上接入 LLM,做一个能自己判断故障、自己执行修复动作的 AIOps Operator——把"人看告警再动手"升级成"系统自己诊断自己修"。