Skip to main content

9. 使用 Kubebuilder

上一章裸写的痛点​

上一章 200 行裸 client-go,能跑,但要自己操心的事太多:

  • Scheme 注册、Informer 创建、Workqueue 初始化——每个 Controller 都要写一遍
  • Worker 循环、退避重试、缓存同步——样板代码,跟业务无关
  • 领导选举、优雅退出、指标暴露——生产必备但手写费劲
  • RBAC 权限——容易漏,漏了就运行时报 403

Kubebuilder 把这些全收走了。你只写 Reconcile 函数——业务逻辑。其他的,框架全包。

打个比方:上一章像自己盖房子——打地基、砌墙、接水管、拉电线,全自己来。这一章像精装修交房——水电通了、墙刷好了,你只管摆家具。

Kubebuilder 是什么​

Kubebuilder 不是一个运行时框架,而是脚手架 + 代码生成器。它生成项目骨架,引入 controller-runtime 库,你在这个骨架里填业务逻辑。

Kubebuilder(脚手架)
│
├── 生成项目结构
├── 生成 CRD YAML(从 Go struct + 注释)
├── 生成 RBAC 权限(从注释)
├── 生成 Webhook 骨架
└── 引入 controller-runtime(运行时框架)
│
├── Manager(生命周期、领导选举)
├── Controller(Informer + Workqueue + Reconcile)
└── Client(封装 client-go,类型安全)

起一个项目​

# 1. 初始化项目
kubebuilder init \
--domain example.com \
--repo github.com/yourname/mysql-operator

# 2. 创建 API(CRD + Controller 一起生成)
kubebuilder create api \
--group database \
--version v1 \
--kind MySQL

# 交互式问答:
# Create Resource [y/n] y
# Create Controller [y/n] y

生成的项目结构:

mysql-operator/
├── api/v1/
│ ├── mysql_types.go # MySQL 资源类型定义
│ ├── groupversion_info.go # Scheme 注册
│ └── zz_generated.deepcopy.go # 自动生成的深拷贝代码
├── cmd/main/ # 入口
├── config/
│ ├── crd/ # CRD YAML
│ ├── rbac/ # RBAC 权限 YAML
│ ├── manager/ # 部署 YAML
│ └── default/ # Webhook 配置
├── internal/controller/
│ └── mysql_controller.go # Reconcile 在这里
└── Makefile

看看省了多少​

资源类型:注释驱动​

api/v1/mysql_types.go:

type MySQLSpec struct {
// +kubebuilder:validation:Minimum=1
// +kubebuilder:validation:Maximum=5
// +kubebuilder:default=1
Replicas int32 `json:"replicas"`

// +kubebuilder:validation:Enum=5.7;8.0
Version string `json:"version"`

// +kubebuilder:validation:Pattern=`^[0-9]+Gi$`
Storage string `json:"storage"`
}

type MySQLStatus struct {
Ready bool `json:"ready"`
Phase string `json:"phase"`
Replicas int32 `json:"replicas"`
}

这些 +kubebuilder: 注释上一章手写过——当时是 // +kubebuilder:object:root=true。这里同样是注释,但作用域扩展了:

注释作用对应上一章什么
validation:Minimum字段最小值CRD schema 里的 minimum
validation:Maximum字段最大值CRD schema 里的 maximum
validation:Enum枚举值CRD schema 里的 enum
validation:Pattern正则校验CRD schema 里的 pattern
default默认值CRD schema 里的 default
optional可选字段不在 required 里

改完类型,跑:

make manifests

Kubebuilder 扫注释,生成 config/crd/mysqls.database.example.com.yaml——上一章手写的那个长 CRD,全自动出。

Reconcile:只写业务​

internal/controller/mysql_controller.go:

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

// 1. 读期望状态
var mysql mygroupv1.MySQL
if err := r.Get(ctx, req.NamespacedName, &mysql); err != nil {
// 被删了
if errors.IsNotFound(err) {
return ctrl.Result{}, nil
}
return ctrl.Result{}, err
}

// 2. 检查删除
if !mysql.DeletionTimestamp.IsZero() {
return r.cleanup(ctx, &mysql)
}

// 3. 确保关联的 StatefulSet
if err := r.ensureStatefulSet(ctx, &mysql); err != nil {
return ctrl.Result{}, err
}

// 4. 更新 status
if err := r.updateStatus(ctx, &mysql); err != nil {
return ctrl.Result{}, err
}

return ctrl.Result{RequeueAfter: 30 * time.Second}, nil
}

跟上一章的 Reconcile 比一下:

上一章本章区别
c.indexer.GetByKey(key)r.Get(ctx, req.NamespacedName, &mysql)类型安全,不用断言
cache.SplitMetaNamespaceKey(key)req.NamespacedName 直接拿不用解析
手写 obj.(*mygroupv1.MySQL)编译器保证类型不会 runtime panic
手写 worker 循环框架调度不用写
fmt.Printflog.FromContext(ctx)结构化日志
手写 ctx 传递ctx context.Context框架注入

核心 Reconcile 逻辑差不多,但省掉了所有样板代码——Informer 创建、Workqueue 调度、worker 循环、退避重试、缓存同步、领导选举、优雅退出、指标暴露,全在 controller-runtime 里。

Manager:生命周期托管​

cmd/main/main.go:

func main() {
var metricsAddr string
var enableLeaderElection bool
flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "Metrics 地址")
flag.BoolVar(&enableLeaderElection, "leader-elect", false, "启用领导选举")
flag.Parse()

mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
Scheme: scheme,
Metrics: server.Options{BindAddress: metricsAddr},
HealthProbeBindAddress: ":8081",
LeaderElection: enableLeaderElection,
LeaderElectionID: "mysql-operator.example.com",
})
if err != nil {
setupLog.Error(err, "启动 Manager 失败")
os.Exit(1)
}

// 注册 Reconciler
if err := (&controllers.MySQLReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
}).SetupWithManager(mgr); err != nil {
os.Exit(1)
}

// 启动
if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil {
os.Exit(1)
}
}

Manager 是 controller-runtime 的核心——上一章你手写的 Run() 函数干的所有事,它都包了:

Manager 干的事上一章怎么写的
启动 Informergo c.informer.Run(stopCh)
等缓存同步cache.WaitForCacheSync
启动 workerwait.Until(c.worker, ...)
领导选举没写,要引入 leaderelection 包
优雅退出没写,要监听 SIGTERM
Prometheus 指标没写
健康检查没写

一行 mgr.Start() 全搞定。

SetupWithManager:声明 Watch 什么​

func (r *MySQLReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&mygroupv1.MySQL{}). // 监听 MySQL
Owns(&appsv1.StatefulSet{}). // 也监听它创建的 StatefulSet
Owns(&corev1.Service{}). // 和 Service
Complete(r)
}

For 声明主资源——MySQL 变了,触发 Reconcile。

Owns 声明从属资源——Controller 创建的 StatefulSet 变了,也触发 Reconcile(带着它的 owner reference 反查到 MySQL)。

上一章手写 Informer 时只 Watch 了 MySQL——StatefulSet 变了你不知道。这里一行 Owns 全配好。

RBAC:注释驱动​

Kubebuilder 从注释生成 RBAC:

//+kubebuilder:rbac:groups=database.example.com,resources=mysqls,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups=database.example.com,resources=mysqls/status,verbs=get;update;patch
//+kubebuilder:rbac:groups=database.example.com,resources=mysqls/finalizers,verbs=update
//+kubebuilder:rbac:groups=apps,resources=statefulsets,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups="",resources=services,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups="",resources=events,verbs=create;patch

func (r *MySQLReconciler) Reconcile(...) (ctrl.Result, error) {
// ...
}

跑 make manifests,生成 config/rbac/role.yaml:

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: manager-role
rules:
- apiGroups: ["database.example.com"]
resources: ["mysqls"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: ["database.example.com"]
resources: ["mysqls/status"]
verbs: ["get", "update", "patch"]
# ...

对比上一章——你手写 RBAC YAML,漏一个 verb 运行时 403。这里注释贴在 Reconciler 上,跟代码同步演进,不会漏。

RBAC 的三个层次​

权限范围资源说明
mysqls主资源读写自己管的 CR
mysqls/statusstatus 子资源Controller 写 status 用
mysqls/finalizersfinalizers 子资源加/删 finalizer 用
statefulsets关联资源Controller 创建的下游资源
services, events关联资源同上

每一类都要单独声明,K8S 默认最小权限——你要什么得说清楚。

Webhook:提交时校验​

CRD 的 schema 只能校验"字段类型对不对"。业务规则——比如"MySQL 副本数必须是奇数"——schema 写不了。Webhook 来填这个空。

两种 Webhook​

类型时机例子
Mutating提交前,可以改字段给空字段填默认值
Validating提交前,只校验不改业务规则校验
用户提交 YAML
│
▼
API Server 收到
│
├─→ Mutating Webhook(改字段)
│
├─→ Schema 校验(类型/范围)
│
├─→ Validating Webhook(业务规则)
│
▼
写入 etcd

生成 Webhook​

kubebuilder create webhook \
--group database \
--version v1 \
--kind MySQL \
--defaulting \
--validation

生成 api/v1/mysql_webhook.go:

// 默认值填充
var _ webhook.Defaulter = &MySQL{}

func (m *MySQL) Default() {
if m.Spec.Replicas == 0 {
m.Spec.Replicas = 1 // 不填就给 1
}
if m.Spec.Version == "" {
m.Spec.Version = "8.0" // 不填就给 8.0
}
}

// 业务校验
var _ webhook.Validator = &MySQL{}

func (m *MySQL) ValidateCreate() error {
return m.validateMySQL()
}

func (m *MySQL) ValidateUpdate(old runtime.Object) error {
return m.validateMySQL()
}

func (m *MySQL) validateMySQL() error {
// 业务规则:副本数必须是奇数(主从模式下偶数会导致选举平局)
if m.Spec.Replicas%2 == 0 {
return fmt.Errorf("MySQL 副本数必须是奇数,当前 %d", m.Spec.Replicas)
}
return nil
}

用户提交 replicas: 2:

$ kubectl apply -f mysql.yaml
Error from server: MySQL.database.example.com "my-db" is invalid:
replicas: Invalid value: 2: MySQL 副本数必须是奇数,当前 2

直接被拦在 API Server,根本到不了 Controller。

对比:上一章 vs 本章​

维度上一章裸写本章 Kubebuilder
代码量200 行50 行 Reconcile
CRD YAML手写注释生成
RBAC YAML手写注释生成
Webhook没写命令生成
领导选举手写一行配置
指标暴露没写自带
健康检查没写自带
优雅退出手写自带
类型安全interface 断言强类型
日志fmt.Printf结构化

省了 150 行样板,但不是没代价——抽象层多了,调试时要懂 controller-runtime 内部。这就是上一章裸写的价值:黑盒出问题时你能钻进去。

跑起来​

# 1. 装 CRD 到集群
make install

# 2. 跑 Controller(本地)
make run

# 3. 提交一个 MySQL
kubectl apply -f config/samples/database_v1_mysql.yaml

# 日志:
# Reconcile MySQL default/mysql-sample: replicas=3, version=8.0

前置阅读​

  • 最精简的Controller — 本章前置,理解裸 Controller 后才看得懂 Kubebuilder 省了什么
  • 控制器模式与CRD — Starter 第 12 章,Controller/CRD 概念入门
  • 安全 — Starter 第 8 章,RBAC 是 K8S 权限模型的基础
  • 编写CRD — 本章前置,Kubebuilder 从 Go 类型生成 CRD

思考题​

问题 1:Owns(&appsv1.StatefulSet{}) 不写会怎样?

问题 2:Mutating Webhook 和 Validating Webhook 谁先执行?为什么是这个顺序?

问题 3:Kubebuilder 生成的 RBAC 默认给 mysqls/status 的 verbs 只有 get;update;patch,没有 create。为什么?


解答​

问题 1:StatefulSet 变化不会触发 MySQL Reconcile。比如有人手动 kubectl scale statefulset 改了副本数,Controller 不知道——要等到下次 MySQL spec 变化或 resync 才会发现并纠正。Owns 的作用是建立"从属资源变化也触发主资源 Reconcile"的关联,让 Controller 能及时响应下游变化。

问题 2:Mutating 先,Validating 后。因为 Mutating 可能改字段——比如把 replicas: 0 改成 1。如果 Validating 先跑,看到的是 0 可能就拒了,根本没机会被 Mutating 修正。所以顺序必须是"先改完,再校验最终结果"。

问题 3:status 子资源是 Controller 写的——它跟主资源 MySQL 是同一个 etcd 对象,主资源 create 时 status 一起出来了。Controller 只是后续 update 它,不需要单独 create。给 create 权限反而是多余的——最小权限原则,用不到的不给。

下一章​

Controller 写完了,下一步是准备部署——镜像构建、Deployment manifest、Leader Election、健康检查,让它跑在生产环境里。