深色模式
开发一个 Operator 实战:从 CRD 到可调谐控制器的可运行骨架
摘要:本文面向生产 SRE / 平台工程师,给出一个可运行的 Operator 开发骨架:用 Kubebuilder 4.x 定义
Database自定义资源,用 controller-runtime 实现Reconcile主循环,管理 StatefulSet/Secret/Service 等子资源,并覆盖 finalizer 清理、状态更新与部署。覆盖版本:Kubebuilder v4.x、controller-runtime v0.18+、Kubernetes v1.30+。
适用版本与前提
- Kubebuilder v4.x(
go/v4布局)、Go 1.22+、controller-runtime v0.18+。 - 一个有
cluster-admin或足够 RBAC 的测试集群(建议先用 kind / minikube 验证,再上生产)。 - 工具:
kubebuilder、kubectl、make、docker、setup-envtest(用于集成测试)。
生产危险
以下骨架是教学/起点,不是生产完备实现。真实的数据库 Operator 还需要:备份/恢复、TLS/密钥轮转、版本升级、健康检查、配额与资源限制、PodDisruptionBudget、监控告警。直接用于生产数据库前必须补齐这些能力并做充分演练。
在任何集群执行 kubectl apply / make deploy 前,先用 kind 等隔离环境验证;生产变更遵循"先备份、再灰度、后全量"。
目标与边界
我们要做的 Database Operator 行为(参考官方 Operator pattern 示例):
- 用户创建
DatabaseCR,声明engine/replicas/storageGi。 - Controller 据此创建/更新一个 StatefulSet(承载数据库)、一个 Service、一个存凭证的 Secret。
- 删除 CR 时,通过 finalizer 先清理外部/子资源再放行删除。
- 周期性 reconcile,保证实际状态持续贴合
.spec。
步骤 1:初始化项目与 API
bash
kubebuilder init --domain example.com --repo example.com/db-operator
kubebuilder create api --group apps --version v1 --kind Database \
--resource --controller1
2
3
2
3
步骤 2:定义 API 类型(api/v1/database_types.go)
go
package v1
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// DatabaseSpec 定义期望状态
type DatabaseSpec struct {
// +kubebuilder:validation:Enum=postgres;mysql
// +kubebuilder:default:=postgres
Engine string `json:"engine,omitempty"`
// +kubebuilder:validation:Minimum=1
// +kubebuilder:validation:Maximum=10
Replicas int32 `json:"replicas,omitempty"`
// 存储容量(Gi)
StorageGi int32 `json:"storageGi,omitempty"`
}
// DatabaseStatus 定义观测状态
type DatabaseStatus struct {
Phase string `json:"phase,omitempty"`
Replicas int32 `json:"replicas,omitempty"`
ReadyReplicas int32 `json:"readyReplicas,omitempty"`
ConnectionString string `json:"connectionString,omitempty"`
}
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:printcolumn:name="Engine",type=string,JSONPath=`.spec.engine`
// +kubebuilder:printcolumn:name="Phase",type=string,JSONPath=`.status.phase`
// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp`
// Database is the Schema for the databases API.
type Database struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec DatabaseSpec `json:"spec,omitempty"`
Status DatabaseStatus `json:"status,omitempty"`
}
// +kubebuilder:object:root=true
// DatabaseList contains a list of Database.
type DatabaseList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []Database `json:"items"`
}
func init() {
SchemeBuilder.Register(&Database{}, &DatabaseList{})
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
生成 deepcopy 与 CRD manifest:
bash
make generate
make manifests1
2
2
步骤 3:实现 Reconcile(controllers/database_controller.go 骨架)
go
package controllers
import (
"context"
"fmt"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
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"
appsv1alpha1 "example.com/db-operator/api/v1"
)
// DatabaseReconciler 调谐 Database
type DatabaseReconciler struct {
client.Client
Scheme *runtime.Scheme
}
// +kubebuilder:rbac:groups=apps.example.com,resources=databases,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=apps.example.com,resources=databases/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=apps,resources=statefulsets,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups="",resources=services;secrets,verbs=get;list;watch;create;update;patch;delete
const databaseFinalizer = "apps.example.com/finalizer"
func (r *DatabaseReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
logger := log.FromContext(ctx)
// 1. 取出 CR
var db appsv1alpha1.Database
if err := r.Get(ctx, req.NamespacedName, &db); err != nil {
if apierrors.IsNotFound(err) {
return ctrl.Result{}, nil // 已删除,无需处理
}
return ctrl.Result{}, err
}
// 2. 处理删除(finalizer)
if !db.DeletionTimestamp.IsZero() {
if controllerutil.ContainsFinalizer(&db, databaseFinalizer) {
if err := r.cleanupExternalResources(ctx, &db); err != nil {
return ctrl.Result{}, err // 重试直到清理成功
}
controllerutil.RemoveFinalizer(&db, databaseFinalizer)
if err := r.Update(ctx, &db); err != nil {
return ctrl.Result{}, err
}
}
return ctrl.Result{}, nil
}
// 3. 确保 finalizer 存在
if !controllerutil.ContainsFinalizer(&db, databaseFinalizer) {
controllerutil.AddFinalizer(&db, databaseFinalizer)
if err := r.Update(ctx, &db); err != nil {
return ctrl.Result{}, err
}
}
// 4. 调谐子资源(幂等:先 Get 再 Create/Update)
if err := r.reconcileStatefulSet(ctx, &db); err != nil {
return ctrl.Result{}, err
}
if err := r.reconcileService(ctx, &db); err != nil {
return ctrl.Result{}, err
}
// 5. 更新状态(写 status 子资源)
db.Status.Phase = "Running"
db.Status.Replicas = db.Spec.Replicas
db.Status.ConnectionString = fmt.Sprintf("%s.%s.svc:5432", db.Name, db.Namespace)
if err := r.Status().Update(ctx, &db); err != nil {
logger.Error(err, "failed to update status")
return ctrl.Result{}, err
}
// 6. 周期性 reconcile(例如检查备份/健康)
return ctrl.Result{RequeueAfter: reconcilePeriod}, nil
}
func (r *DatabaseReconciler) reconcileStatefulSet(ctx context.Context, db *appsv1alpha1.Database) error {
sts := &appsv1.StatefulSet{}
err := r.Get(ctx, client.ObjectKey{Name: db.Name, Namespace: db.Namespace}, sts)
if apierrors.IsNotFound(err) {
sts = r.desiredStatefulSet(db)
// 设置 owner reference,保证 CR 删除时级联删除
if err := ctrl.SetControllerReference(db, sts, r.Scheme); err != nil {
return err
}
return r.Create(ctx, sts)
}
if err != nil {
return err
}
// 规格漂移则更新(生产应做不可变字段校验)
desired := r.desiredStatefulSet(db)
sts.Spec.Replicas = desired.Spec.Replicas
return r.Update(ctx, sts)
}
func (r *DatabaseReconciler) desiredStatefulSet(db *appsv1alpha1.Database) *appsv1.StatefulSet {
replicas := db.Spec.Replicas
return &appsv1.StatefulSet{
ObjectMeta: metav1.ObjectMeta{Name: db.Name, Namespace: db.Namespace},
Spec: appsv1.StatefulSetSpec{
Replicas: &replicas,
ServiceName: db.Name,
Selector: &metav1.LabelSelector{
MatchLabels: map[string]string{"app": db.Name},
},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"app": db.Name}},
Spec: corev1.PodSpec{
Containers: []corev1.Container{{
Name: "db",
Image: "postgres:16",
Ports: []corev1.ContainerPort{{ContainerPort: 5432}},
}},
},
},
},
}
}
func (r *DatabaseReconciler) reconcileService(ctx context.Context, db *appsv1alpha1.Database) error {
// 省略:类似地 Get/Create 一个 ClusterIP Service,
// 并对 selector=app:db.Name 设置 owner reference。
return nil
}
func (r *DatabaseReconciler) cleanupExternalResources(ctx context.Context, db *appsv1alpha1.Database) error {
// 真实场景:删外部备份、回收云盘、撤销 DNS 等。
// 子资源(StatefulSet/Service)已通过 owner reference 自动级联删除。
return nil
}
// SetupWithManager 注册 controller
func (r *DatabaseReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&appsv1alpha1.Database{}).
Owns(&appsv1.StatefulSet{}).
Owns(&corev1.Service{}).
Complete(r)
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
注意
上面的 reconcilePeriod 与 cleanupExternalResources/reconcileService 是占位;真实实现需定义 const reconcilePeriod = 30 * time.Second 并补全 Service 逻辑。代码以"结构正确、风格正确"为目标,生产前请完整实现并补齐测试。
步骤 4:构建、测试与部署
bash
# 生成 RBAC/CRD manifest(来自 marker 注解)
make manifests
# 运行集成测试(envtest 会拉起真实 apiserver+etcd)
make test
# 本地先装 CRD 并跑 controller,便于调试
make install
make run
# 验证
kubectl apply -f config/samples/apps_v1_database.yaml
kubectl get databases
kubectl get sts1
2
3
4
5
6
7
8
9
10
11
2
3
4
5
6
7
8
9
10
11
bash
# 构建镜像并部署到集群
make docker-build docker-push IMG=registry.example.com/db-operator:v0.1.0
make deploy IMG=registry.example.com/db-operator:v0.1.01
2
3
2
3
架构图
生产实践
- finalizer 必加:凡是要清理"外部副作用"(云资源、备份、DNS)的 Operator,都要用 finalizer 保证删除前清理,否则产生孤儿资源与成本泄漏。
- owner reference 必设:子资源设置
SetControllerReference,让 CR 删除时自动级联回收,避免手动清理遗漏。 - 状态写 status 子资源:
r.Status().Update需要databases/status的 RBAC(marker 已声明)。不要把观测状态写回.spec。 - 资源与可用性:controller 自身部署 ≥2 副本 + PodDisruptionBudget + leader election;配置
resources、探针。
常见失败模式
- CR 卡 Terminating:finalizer 没被正确处理(controller 挂了或清理逻辑报错一直重试失败)→ 检查 controller 日志与 finalizer 列表。
- 状态更新竞争:多个 reconcile 并发写 status 导致
resourceVersion冲突(409)。controller-runtime 默认单协程处理同一 key,但跨资源更新仍需注意;必要时用 patch。 - Update 全量覆盖:直接用
r.Update覆盖对象会丢失他人并发修改。优先用r.Patch或只改必要字段。 - RBAC 缺
.../status权限:状态更新报forbidden。marker 已覆盖,但手写 manifest 时易漏。
故障排查
bash
kubectl logs deploy/db-operator-controller-manager -n db-operator-system
kubectl get database orders-db -o yaml # 看 status / finalizers / conditions
kubectl get events -n <ns> --sort-by=.lastTimestamp
# 查看 reconcile 错误与 workqueue 指标1
2
3
4
2
3
4
回滚与清理
bash
# 删除 CR(触发 finalizer 清理子资源)
kubectl delete -f config/samples/apps_v1_database.yaml
# 卸载 operator(先删 CR 再卸,避免级联删除风险)
make undeploy
make uninstall1
2
3
4
5
2
3
4
5
生产危险
make uninstall 会删除 CRD 并级联删除所有 Database CR 与子资源(不可恢复)。执行前务必 kubectl get databases -A -o yaml > backup.yaml。
性能、容量与成本
- 单 Database 对应一组 StatefulSet/Pod/存储;大规模多租户需评估节点资源、PV 供给速率与 controller 的 reconcile 并发。
- 周期
RequeueAfter不宜过短,否则在大量 CR 时形成持续 reconcile 压力;可按对象数量动态退避。
FAQ
Q:Reconcile 里能不能直接调外部云 API? A:可以,但要处理失败重试、幂等与限流,且避免在 reconcile 内做长阻塞调用;耗时操作建议放入 workqueue 的异步 worker 或外部任务。
Q:为什么不用简单的脚本轮询? A:脚本缺乏 informer 缓存、水平驱动自愈、leader election、RBAC 集成与 kubectl 原生体验;Operator 把这些工程难题标准化了。
参考资料
- The Kubebuilder Book - Quick Start,访问日期:2026-10-08。
- Kubernetes 官方文档 - Operator pattern,访问日期:2026-10-08。
- controller-runtime 文档(Reconciler / Builder),访问日期:2026-10-08。
- Kubebuilder v4.0.0 Release Notes,访问日期:2026-10-08。