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.Printf | log.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 干的事 | 上一章怎么写的 |
|---|---|
| 启动 Informer | go c.informer.Run(stopCh) |
| 等缓存同步 | cache.WaitForCacheSync |
| 启动 worker | wait.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/status | status 子资源 | Controller 写 status 用 |
mysqls/finalizers | finalizers 子资源 | 加/删 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、健康检查,让它跑在生产环境里。