🔍 登录 注册

Kubernetes Operator 开发实战 从一个 CRD 到稳定 reconcile

今 今晚打老虎 2026-10-23 👁 0 ⏱ 7min

Kubernetes Operator 开发实战 从一个 CRD 到稳定 reconcile

Operator 的价值是把运维知识编码进控制器:声明期望状态,控制器持续把现实推到期望。听起来简单,写出"不抖动、不打架、可回滚"的 Operator 有一整套纪律。本文按 controller-runtime 的实际开发顺序走一遍。

一、CRD 设计的三条原则

type CacheSpec struct {
    Replicas    int32                     `json:"replicas"`
    Image       string                    `json:"image"`
    Resources   corev1.ResourceRequirements `json:"resources,omitempty"`
    // 不要用 map[string]string 塞配置,字段要有语义
    Eviction    *EvictionPolicy           `json:"eviction,omitempty"`
}

type CacheStatus struct {
    ObservedGeneration int64              `json:"observedGeneration,omitempty"`
    Phase              string             `json:"phase"`      // Progressing/Ready/Degraded
    Conditions         []metav1.Condition `json:"conditions,omitempty"`
    ReadyReplicas      int32              `json:"readyReplicas"`
}

三条原则:

  1. Spec 里只放用户意图,Status 里放观测结果,两者绝不互写;
  2. 必须带 observedGeneration,否则客户端无法判断 status 是否对应最新 spec;
  3. Status 用 Conditions 表达状态,别自造一堆 bool 字段。

kubebuilder 一键生成骨架:

kubebuilder init --domain okgoes.com --repo github.com/okgoes/cache-operator
kubebuilder create api --group cache --version v1alpha1 --kind RedisCache
make generate manifests

二、Reconcile 的幂等纪律

控制器可能被并发、重入、乱序调用。所有逻辑必须写成"期望态 vs 实际态"的收敛,而不是"执行一系列动作"。

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

    var cr cachev1.RedisCache
    if err := r.Get(ctx, req.NamespacedName, &cr); err != nil {
        return ctrl.Result{}, client.IgnoreNotFound(err)
    }
    if !cr.DeletionTimestamp.IsZero() {
        return r.finalize(ctx, &cr)
    }

    if err := r.ensureConfigMap(ctx, &cr); err != nil {
        return ctrl.Result{}, fmt.Errorf("ensure configmap: %w", err)
    }
    dep, err := r.ensureDeployment(ctx, &cr)
    if err != nil {
        return ctrl.Result{}, fmt.Errorf("ensure deployment: %w", err)
    }

    ready := dep.Status.ReadyReplicas
    if err := r.patchStatus(ctx, &cr, ready); err != nil {
        return ctrl.Result{}, err
    }
    if ready < *cr.Spec.Replicas {
        log.Info("waiting for replicas", "ready", ready)
        return ctrl.Result{RequeueAfter: 10 * time.Second}, nil
    }
    return ctrl.Result{}, nil
}

关键点:

  • 用 CreateOrUpdate 或 controllerutil.CreateOrPatch,并在 mutation 函数里只改你拥有的字段;
  • 给从属资源设 OwnerReference,级联删除靠 GC;
  • Status 更新走 Status().Patch(),避免与 Spec 写冲突。

三、错误处理决定稳定性

永久错误与暂时错误的区分是 Operator 最容易做错的地方。返回 error 会触发指数退避重入队;返回 RequeueAfter 是"定时再来一次"。

if err := r.ensureIngress(ctx, cr); err != nil {
    if isValidError(err) {
        // 用户写错配置:不要疯狂重试刷日志,写进 Condition 等用户改
        meta.SetStatusCondition(&cr.Status.Conditions, metav1.Condition{
            Type: "Ready", Status: metav1.ConditionFalse, Reason: "InvalidSpec",
            Message: err.Error(), ObservedGeneration: cr.Generation,
        })
        _ = r.Status().Update(ctx, cr)
        return ctrl.Result{}, nil
    }
    return ctrl.Result{}, err     // 暂时错误:apiserver 抖动、配额未满等,交给退避
}

反例是把配置校验错误也 return err,结果每几秒重入、事件刷屏、workqueue_depth 飙升。

四、Finalizer 与外部资源

Operator 常管外部资源(云数据库、DNS 记录)。K8s 删除 CR 时,必须靠 finalizer 保证外部资源同步清理。

const finalizer = "cache.okgoes.com/finalizer"

func (r *RedisCacheReconciler) finalize(ctx context.Context, cr *cachev1.RedisCache) (ctrl.Result, error) {
    if !controllerutil.ContainsFinalizer(cr, finalizer) {
        return ctrl.Result{}, nil
    }
    if err := r.cloud.DeleteInstance(ctx, cr.Spec.InstanceID); err != nil && !isNotFound(err) {
        return ctrl.Result{}, err                 // 清理失败要能重试,别吞掉
    }
    patch := client.MergeFrom(cr.DeepCopy())
    controllerutil.RemoveFinalizer(cr, finalizer)
    return ctrl.Result{}, r.Patch(ctx, cr, patch)
}

两条铁律:外部删除必须幂等(已删除视作成功);finalizer 清理失败必须返回 error,否则资源泄漏。

五、控制并发、限流与租约

多副本部署时必须靠 leader election 保证只有一个实例在 reconcile。

mgr, err := ctrl.NewManager(cfg, ctrl.Options{
    LeaderElection:          true,
    LeaderElectionID:        "cache-operator.okgoes.com",
    LeaderElectionNamespace: "okgoes-system",
})

单实例内也要限并发,避免一次大规模变更打爆 apiserver:

controller.New("rediscache", mgr, controller.Options{
    Reconciler: r, MaxConcurrentReconciles: 4,
    RateLimiter: workqueue.NewMaxOfRateLimiter(
        workqueue.NewItemExponentialFailureRateLimiter(100*time.Millisecond, 2*time.Minute),
        &workqueue.BucketRateLimiter{Limiter: rate.NewLimiter(rate.Limit(20), 50)},
    ),
})

再加事件过滤,别为无关字段变化空转:

For(&cachev1.RedisCache{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})).
Owns(&appsv1.Deployment{}, builder.WithPredicates(predicate.Or(
    predicate.GenerationChangedPredicate{},
    predicate.LabelChangedPredicate{},
)))

六、可观测性与灰度

必备指标:

controller_runtime_reconcile_total{result="error"}   # 错误率
controller_runtime_reconcile_time_seconds_bucket     # 单次耗时 P99
workqueue_depth / workqueue_adds_total                # 队列积压

告警就两条:rate(reconcile_total{result="error"}[5m]) > 0.01 与 workqueue_depth > 100 持续 5 分钟。

发布上遵守"CRD 版本先行、控制器随后":CRD 可以提前发(新增可选字段不破坏既有对象),控制器升级要能同时处理新旧 spec,回滚时靠 observedGeneration 判断是否需要重算。

小结

Operator 的核心不是"操作 K8s API",而是三件事:CRD 契约设计(Spec/Status 分离 + Conditions + observedGeneration)、reconcile 幂等且区分错误类型、并发与限流保护 API Server。加上 finalizer 正确清理外部资源和四个关键指标,控制器就能长期稳定运行。先用 kubebuilder 跑通脚手架,再把这四条纪律逐条落实,比一开始追求复杂功能重要得多。

读完了?给作者一点支持

本文采用 CC BY 4.0 协议转载 · © 今晚打老虎

评论(0)

登录 后参与评论。

  • 还没有评论,来抢沙发。

🔗 相关文章