K8s CRD 与 Operator 开发:别让自定义资源变成运维负债

K8s CRD 与 Operator 开发:别让自定义资源变成运维负债 K8s CRD 与 Operator 开发别让自定义资源变成运维负债一、我们写了个 Operator现在没人敢动它Operator 解决了运维自动化的痛点但引入了新的问题它是一个运行中的软件需要维护、升级、排错。写得不好的 Operator 比手动运维更危险——因为它会自动地做错事。CRD Operator 开发的陷阱不在于 Kubernetes 的 API 有多复杂而在于状态模型设计错误——CRD 的 spec/status 划分不合理调谐循环逻辑错误——反复尝试修复一个无法修复的状态没有优雅降级——依赖的外部服务挂了Operator 自己也不工作了flowchart TD A[CRD 定义] -- B{状态模型设计} B -- C[Spec: 用户期望状态] B -- D[Status: 系统实际状态] C -- E{调谐循环} E -- F[1. 读取 Spec] F -- G[2. 观察当前状态] G -- H{3. 比较差异} H --|一致| I[不做任何操作] H --|不一致| J[4. 执行调谐操作] J -- K[5. 更新 Status] K -- L{操作成功?} L --|是| M[等待下次调谐] L --|否| N{可重试?} N --|是| O[指数退避重试] N --|否| P[更新 Status 错误信息] O -- M P -- M M -- E二、CRD 设计的三条铁律铁律一Spec 是期望Status 是现实很多 Operator 把 Spec 和 Status 混在一起。正确做法Spec用户说我要 3 个副本——只写不变Status系统报告当前 2 个副本就绪1 个启动中——只读铁律二Status 必须包含 Conditions不要只写status.phase: Running。用标准的 Conditions 数组——每个条件有 Type、StatusTrue/False/Unknown、Reason、Message。铁律三Spec 变更向后兼容不能删除已有字段、不能改变字段类型、不能改变默认值语义。遵循 K8s API 兼容性约定——可以加新字段但不能改旧字段。三、生产级 Operator 实现package main import ( context fmt time corev1 k8s.io/api/core/v1 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 sigs.k8s.io/controller-runtime/pkg/reconcile ) // // CRD 定义 // // AppServiceSpec 定义了用户期望的应用状态。 // kubebuilder:object:generatetrue type AppServiceSpec struct { // 副本数——Spec 中最基本的字段 Replicas int32 json:replicas // 镜像——变更后触发滚动更新 Image string json:image // 端口——应用监听的端口 Port int32 json:port // 数据库连接配置——引用 Secret DatabaseRef corev1.SecretReference json:databaseRef } // AppServiceStatus 定义了系统当前的状态。 type AppServiceStatus struct { // Conditions 是标准的 K8s 条件数组。 // 每个条件表示一个维度的状态。 Conditions []metav1.Condition json:conditions,omitempty // 当前就绪的副本数 ReadyReplicas int32 json:readyReplicas // 最近一次调谐的时间 LastReconcileTime *metav1.Time json:lastReconcileTime,omitempty } // AppService 是自定义资源的顶层结构。 // kubebuilder:object:roottrue // kubebuilder:subresource:status type AppService struct { metav1.TypeMeta json:,inline metav1.ObjectMeta json:metadata,omitempty Spec AppServiceSpec json:spec,omitempty Status AppServiceStatus json:status,omitempty } // 定义 Conditions 的类型常量 const ( ConditionTypeReady Ready ConditionTypeDegraded Degraded ) // // Controller / Reconciler // type AppServiceReconciler struct { client.Client Scheme *runtime.Scheme } // Reconcile 是调谐循环的入口。 // 关键设计 // - 幂等多次调谐产生相同结果 // - 快速返回不要在 reconcile 里做耗时操作 // - 错误传播出错时返回 errorcontroller-runtime 自动重试 func (r *AppServiceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { logger : log.FromContext(ctx) // Step 1: 获取 CR 实例 var app AppService if err : r.Get(ctx, req.NamespacedName, app); err ! nil { // CR 被删除——不需要调谐 return ctrl.Result{}, client.IgnoreNotFound(err) } // Step 2: 调谐 Deployment deploy, err : r.reconcileDeployment(ctx, app) if err ! nil { // 更新 Degraded 条件 r.updateCondition(ctx, app, ConditionTypeDegraded, metav1.ConditionTrue, DeploymentFailed, err.Error()) return ctrl.Result{RequeueAfter: 30 * time.Second}, err } // Step 3: 调谐 Service if err : r.reconcileService(ctx, app); err ! nil { r.updateCondition(ctx, app, ConditionTypeDegraded, metav1.ConditionTrue, ServiceFailed, err.Error()) return ctrl.Result{RequeueAfter: 30 * time.Second}, err } // Step 4: 更新 Status app.Status.ReadyReplicas deploy.Status.ReadyReplicas app.Status.LastReconcileTime metav1.Time{Time: time.Now()} // 根据就绪副本数判断 Ready 状态 readyStatus : metav1.ConditionFalse readyReason : NotReady if deploy.Status.ReadyReplicas app.Spec.Replicas { readyStatus metav1.ConditionTrue readyReason AllReplicasReady } r.updateCondition(ctx, app, ConditionTypeReady, readyStatus, readyReason, ) r.updateCondition(ctx, app, ConditionTypeDegraded, metav1.ConditionFalse, OK, ) if err : r.Status().Update(ctx, app); err ! nil { return ctrl.Result{}, fmt.Errorf(update status: %w, err) } // Step 5: 返回结果 // RequeueAfter: 即使没有错误也定期重试——这是 Operator 的常规做法 // 因为外部状态可能变化如 Deployment 被手动改了 return ctrl.Result{RequeueAfter: 5 * time.Minute}, nil } func (r *AppServiceReconciler) reconcileDeployment( ctx context.Context, app *AppService, ) (*appsv1.Deployment, error) { // 构建期望的 Deployment 对象 desired : r.buildDeployment(app) // 使用 controllerutil 做 CreateOrUpdate // 这个函数是 K8s Controller 的核心工具——自动处理创建/更新逻辑 op, err : controllerutil.CreateOrUpdate(ctx, r.Client, desired, func() error { // 这个闭包只有在执行 Update 时才会执行 // 如果是 Create跳过 // 关键只修改 mutable 的字段不可变字段不要改 desired.Spec.Replicas app.Spec.Replicas desired.Spec.Template.Spec.Containers[0].Image app.Spec.Image // 设置 owner reference——CR 删除时自动清理 Deployment return controllerutil.SetControllerReference(app, desired, r.Scheme) }) if err ! nil { return nil, fmt.Errorf(create or update deployment: %w, err) } log.FromContext(ctx).Info(Deployment reconciled, operation, op, name, desired.Name) return desired, nil } // updateCondition 更新 Status 中的条件。 // 条件更新是幂等的——相同状态不重复更新。 func (r *AppServiceReconciler) updateCondition( ctx context.Context, app *AppService, condType string, status metav1.ConditionStatus, reason, message string, ) { newCondition : metav1.Condition{ Type: condType, Status: status, Reason: reason, Message: message, LastTransitionTime: metav1.Time{Time: time.Now()}, ObservedGeneration: app.Generation, } // 检查是否与现有条件相同——如果相同则不需要更新 for i, existing : range app.Status.Conditions { if existing.Type condType { if existing.Status status existing.Reason reason { return // 无变化跳过 } // 保留原有的 LastTransitionTime if existing.Status ! status { newCondition.LastTransitionTime metav1.Time{Time: time.Now()} } else { newCondition.LastTransitionTime existing.LastTransitionTime } app.Status.Conditions[i] newCondition return } } app.Status.Conditions append(app.Status.Conditions, newCondition) } func (r *AppServiceReconciler) buildDeployment(app *AppService) *appsv1.Deployment { labels : map[string]string{app: app.Name} replicas : app.Spec.Replicas return appsv1.Deployment{ ObjectMeta: metav1.ObjectMeta{ Name: app.Name, Namespace: app.Namespace, Labels: labels, }, Spec: appsv1.DeploymentSpec{ Replicas: replicas, Selector: metav1.LabelSelector{MatchLabels: labels}, Template: corev1.PodTemplateSpec{ ObjectMeta: metav1.ObjectMeta{Labels: labels}, Spec: corev1.PodSpec{ Containers: []corev1.Container{{ Name: app, Image: app.Spec.Image, Ports: []corev1.ContainerPort{{ ContainerPort: app.Spec.Port, }}, // resources 没有在 CRD 中暴露——使用默认值 // 生产环境应该通过 CRD 暴露 Resources: corev1.ResourceRequirements{ Requests: corev1.ResourceList{ corev1.ResourceCPU: resource.MustParse(100m), corev1.ResourceMemory: resource.MustParse(128Mi), }, }, }}, }, }, }, } } // SetupWithManager 注册 Controller。 func (r *AppServiceReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). For(AppService{}). // 监听 AppService 资源 Owns(appsv1.Deployment{}). // 同时监听子资源 Deployment Complete(r) }四、Operator 的维护成本升级风险K8s API 版本升级时CRD 的 API 版本如apiextensions.k8s.io/v1beta1→v1可能 breaking change。每次 K8s 大版本升级前需要检查 Operator 的兼容性。依赖外部系统的脆弱性Operator 常依赖外部系统数据库、云服务 API。如果外部系统不可用调谐循环会不断重试——这可能导致日志风暴和 CPU 空转。不要写 Operator 的场景标准 Deployment Service 能描述的应用不需要复杂运维逻辑的单体应用团队没有 Go 开发能力的场景CRD 如果只有一个 on/off 字段不如用 ConfigMap五、总结CRD Operator 的正确姿势小步迭代。从最简单的 Spec只暴露镜像和副本数开始Status 先只放 Conditions 数组。调谐循环保持幂等和快速返回。不是所有运维操作都需要 Operator——能用 Deployment HPA ConfigMap 解决的就别碰 CRD。