Skip to content

Reconcile 循环详解

本篇深入 Reconcile 循环的每一个细节。第三章我们写出了一个能跑的 Operator,但 Reconcile 的返回值语义、Owner Reference 机制、CreateOrUpdate 模式、事件过滤、多资源 Watch 等关键点都还是一笔带过。这些细节决定了 Operator 在生产环境是否健壮。本篇会把这些点逐一拆解,最后给出一个带 ConfigMap 配置管理的完整示例,让你彻底掌握 Reconcile 的写法。

一、Reconcile 函数签名与返回值

回顾 Reconcile 的签名:

go
func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error)

它返回 (ctrl.Result, error)。这两者组合起来表达「这次 Reconcile 之后接下来怎么办」,一共有四种语义,理解它们是写好 Reconcile 的前提。

1. ctrl.Result{RequeueAfter: duration} + nil error

「这次处理完了(或还没完成),请过 duration 之后再 Reconcile 一次。」

这是最常见的「周期性观察」手段。比如你创建了一个 Deployment,但它还没就绪,你不希望一直阻塞等待,就告诉 Controller「10 秒后再来看看」。注意 RequeueAfterRequeue 是两个字段:

  • RequeueAfter: time.Duration固定间隔重新入队。Controller 会在 duration 后再次触发 Reconcile,即使期间没有任何事件。
  • Requeue: true立即重新入队。相当于「马上再来一次」。

两者同时设置时,RequeueAfter 优先。生产代码里绝大多数情况用 RequeueAfter,因为 Requeue: true 容易导致紧密循环打爆 API Server。

go
// 10 秒后再观察
return ctrl.Result{RequeueAfter: 10 * time.Second}, nil

2. ctrl.Result{} + nil error

「这次处理完了,且当前期望状态已达成,不需要主动再次 Reconcile。等到下次有相关事件再叫我。」

这是「正常完成」的返回。Controller 不会再主动触发 Reconcile,只有当 Watch 的资源发生变化(比如用户改了 spec、关联 Pod 状态变化)才会再次进入。

go
// 一切就绪
return ctrl.Result{}, nil

3. ctrl.Result{} + error

「这次处理出错了,请按指数退避策略自动重试。」

返回非 nil error 时,Controller 会把这个请求重新放回队列,并按指数退避(默认从 5ms 开始,最大 1000s)重试。这意味着你不需要自己写重试逻辑——遇到暂时性错误(网络抖动、API Server 5xx、冲突),直接返回 error 让框架重试即可。

go
if err := r.Update(ctx, dep); err != nil {
    // 冲突、网络错误等,让框架重试
    return ctrl.Result{}, err
}

4. ctrl.Result{} + nil error(但其实是「不需要重试的永久失败」)

有些错误是逻辑性的,重试也没用(比如配置不合法)。这时可以记录日志/事件后返回 nil,避免无限重试。或者把这种错误编码进 Status(比如 phase: Failed)。

go
if rc.Spec.Size < 1 {
    logger.Info("size 非法,忽略", "size", rc.Spec.Size)
    // 不返回 error,避免框架无限重试
    return ctrl.Result{}, nil
}

返回值决策表

场景Resulterror
期望状态已达成ctrl.Result{}nil
等待异步操作(Pod 就绪等)Result{RequeueAfter: N}nil
暂时性错误(冲突/网络)ctrl.Result{}error
逻辑性错误(非法配置)ctrl.Result{}nil(记日志)

二、Reconcile 循环的工作流

把 Reconcile 内部逻辑抽象成五个阶段,几乎所有 Operator 都遵循这个骨架:

┌─────────────────────────────────────────────────────────┐
│  1. 获取 CR 实例                                          │
│     r.Get(...) → 不存在则返回(级联删除已处理)           │
└─────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────┐
│  2. 检查期望状态 vs 实际状态                              │
│     列出关联资源(Deployment/Pod/Service)                │
│     比对 spec 与实际                                      │
└─────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────┐
│  3. 创建/更新/删除资源,消除差异                          │
│     不存在 → Create                                       │
│     存在但 spec 漂移 → Update                             │
│     多余的 → Delete                                       │
└─────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────┐
│  4. 更新 Status                                          │
│     收集实际状态,写入 status 子资源                       │
└─────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────┐
│  5. 返回结果                                              │
│     就绪 → Result{}                                       │
│     未就绪 → Result{RequeueAfter: N}                      │
└─────────────────────────────────────────────────────────┘

1. 获取 CR 实例

第一步永远是 Get。不要假设 CR 还在,因为 Reconcile 请求是异步的——可能在入队后、被处理前,CR 就被删了。

go
var rc cachev1.RedisCluster
if err := r.Get(ctx, req.NamespacedName, &rc); err != nil {
    if errors.IsNotFound(err) {
        return ctrl.Result{}, nil
    }
    return ctrl.Result{}, err
}

2. 比对状态

获取关联资源,判断实际状态。常用的有几种模式:

go
// 模式 A:按名字精确获取(关联资源名字与 CR 相同)
var dep appsv1.Deployment
err := r.Get(ctx, types.NamespacedName{Name: rc.Name, Namespace: rc.Namespace}, &dep)

// 模式 B:按 label 列出(关联资源名字不固定,比如每个 Pod 名不同)
var pods corev1.PodList
err := r.List(ctx, &pods, client.InNamespace(rc.Namespace),
    client.MatchingLabels{"app.kubernetes.io/instance": rc.Name})

3. 消除差异

这是 Reconcile 的核心。原则是「只做必要的操作」——不存在才创建,变了才更新,多余的才删除。无谓的 Update 会改变 resourceVersion,引发额外的 Watch 事件,可能造成「无限 Reconcile」。

4. 更新 Status

收集实际状态后写入 Status。注意:Status 更新和 Spec 调谐最好分开做,且 Status 更新要用 Status().Update()Status().Patch()(第五章详解)。

5. 返回结果

如果一切就绪返回 {};如果还有未完成的异步过程(Pod 还在启动、外部资源还在创建),返回 {RequeueAfter: N} 让框架定时回来看。

三、Owner Reference:级联删除

1. 什么是 Owner Reference

K8s 中每个对象可以有一个 ownerReferences 字段,指向它的「所有者」。当 Owner 被删除时,K8s 的垃圾回收器(GC)会自动删除所有引用它的对象。这就是级联删除。

yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-redis
  ownerReferences:
    - apiVersion: cache.example.com/v1
      kind: RedisCluster
      name: my-redis
      uid: 7c4f...
      controller: true        # 这个 owner 是控制器
      blockOwnerDeletion: true # 阻止在它删除前删 Owner(可选)

2. 在 Operator 中设置 Owner

controller-runtime 提供 controllerutil.SetControllerReference

go
import "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"

dep := &appsv1.Deployment{...}
if err := controllerutil.SetControllerReference(rc, dep, r.Scheme); err != nil {
    return err
}
r.Create(ctx, dep)

第三个参数 r.Scheme 用来查找 Owner 的 GVK(Group/Version/Kind)。SetControllerReference 必须在 Create 之前调用。

3. 级联删除的工作原理

当用户执行 kubectl delete rediscluster my-redis

  1. K8s 给 RedisCluster 设置 deletionTimestamp
  2. GC 控制器扫描所有 ownerReferences 指向它的对象。
  3. 把这些对象(Deployment、Service)逐个删除。
  4. 所有从属对象删完后,RedisCluster 自己才被真正删除。

注意:Finalizer 和级联删除是两套机制。级联删除只能清理集群内的 K8s 资源,清理外部资源(如云上的数据库实例)必须用 Finalizer(第六章)。本篇只讲级联删除,Finalizer 下一章讲。

4. 级联删除策略

K8s 支持三种级联删除策略,通过 propagationPolicy 控制:

  • Foreground:先删从属对象,再删 Owner。Owner 在所有从属删完前一直处于 Terminating 状态。
  • Background(默认):先删 Owner,再异步删从属。
  • Orphan:不删从属,只删 Owner,从属变成孤儿。

Operator 通常不需要关心这个,默认 Background 即可。如果你的 Operator 创建的资源需要「严格顺序」删除,才需要切换到 Foreground 或用 Finalizer 自己控制顺序。

四、资源创建与更新策略

1. CreateOrUpdate 模式

controller-runtime 提供 controllerutil.CreateOrUpdate,把「Get → 比较 → Create/Update」三步合一:

go
_, err := controllerutil.CreateOrUpdate(ctx, r.Client, dep, func() error {
    // 这个 mutate 函数会被调用,用于把期望状态写进 dep
    // dep 此时可能是空对象(要 Create)或已存在对象(要 Update)
    labels := labelsForRedis(rc.Name)
    dep.ObjectMeta.Labels = labels
    dep.Spec.Replicas = &rc.Spec.Size
    dep.Spec.Selector = &metav1.LabelSelector{MatchLabels: labels}
    dep.Spec.Template = corev1.PodTemplateSpec{
        ObjectMeta: metav1.ObjectMeta{Labels: labels},
        Spec: corev1.PodSpec{
            Containers: []corev1.Container{{
                Name:  "redis",
                Image: rc.Spec.Image,
            }},
        },
    }
    return nil
})

CreateOrUpdate 的行为:

  • 资源不存在:调用 mutate 后 Create
  • 资源存在但 mutate 后内容变了:Update
  • 资源存在且 mutate 后内容没变:什么都不做(它内部用 reflect.DeepEqual 比较)。

返回的 bool 表示是否执行了更新操作。

重要注意:mutate 函数里只能设置「期望值」,不能依赖 dep 已有的值做累加(比如 dep.Spec.Replicas = ptr.To(*dep.Spec.Replicas + 1) 是错的),因为 Update 模式下 dep 是已存在对象,Create 模式下 dep 是空对象,两种情况下行为不一致,容易出 bug。

2. CreateOrUpdate 的局限性

CreateOrUpdate 内部用 reflect.DeepEqual 判断是否需要 Update。它的比较规则有两个坑:

  1. 默认字段会被认为是「变化」:比如 TypeMetaStatus 这些字段在新建对象时是零值,DeepEqual 会判定为不同,导致每次都 Update。
  2. server-side 字段(如 clusterIP)会被覆盖:Service 的 clusterIP 是 API Server 分配的,如果你在 mutate 里没保留,Update 会把已有 clusterIP 清空,导致冲突。

因此对于 Service 这种有不可变/服务端分配字段的资源,建议手动 Get + 比较 + Update,只更新你关心的字段(第三章 reconcileService 就是这么做的)。

3. Patch vs Update

除了 Update,controller-runtime 还支持 Patch。二者区别:

  • Update:发送整个对象(除 status 子资源),API Server 用新对象覆盖旧对象。需要先 Get 拿到最新 resourceVersion,否则会冲突。
  • Patch:只发送变化的字段(JSON Patch / Strategic Merge Patch / Apply),API Server 合并。冲突更少,带宽更省。
维度UpdatePatch
网络开销大(整个对象)小(仅变化字段)
冲突概率高(需最新 resourceVersion)低(按字段合并)
并发友好
易用性高(直接改对象)中(需构造 patch)

controller-runtime Client 支持 PatchPatchStatus

go
import (
    "k8s.io/apimachinery/pkg/types"
)

// JSON Merge Patch
patch := []byte(`{"spec":{"replicas":5}}`)
err := r.Patch(ctx, dep, client.RawPatch(types.MergePatchType, patch))

// Status Patch(推荐用于更新 status,比 Status().Update 更省)
statusPatch := client.MergeFrom(rc.DeepCopy())
rc.Status.Phase = "Running"
err := r.Status().Patch(ctx, rc, statusPatch)

client.MergeFrom(base) 是最推荐的 Patch 构造方式:它自动计算 base 和当前对象的差异,生成 Strategic Merge Patch。先 base := rc.DeepCopy(),再修改 rc,再 Patch(ctx, rc, client.MergeFrom(base)),框架帮你算 diff。

生产建议

  • 创建资源用 Create
  • 更新 Spec 用 UpdateCreateOrUpdate(简单场景)或 Patch(高并发场景)。
  • 更新 Status 用 Status().Patch 配合 MergeFrom(第五章详解)。

五、事件过滤:Predicate

1. 为什么需要 Predicate

每次 Reconcile 都会消耗 CPU 和 API 调用。如果不过滤,所有事件(包括 status 变化、label 变化、定时 resync)都会触发 Reconcile。对于规模大的集群,这会显著增加负载。

Predicate 让你在事件进入 Reconcile 队列前就把它过滤掉。常见场景:

  • 只有 spec 变化才 Reconcile(status/label 变化忽略)。
  • 只有特定 label 的资源才 Reconcile。

2. Generation 变化过滤

metadata.generation 是 K8s 维护的计数器,只在 spec 变化时递增(status 变化不递增)。所以「generation 变化」等价于「spec 变化」。

GenerationChangedPredicate 是最常用的 Predicate,几乎所有主资源都该加:

go
import "sigs.k8s.io/controller-runtime/pkg/predicate"

ctrl.NewControllerManagedBy(mgr).
    For(&cachev1.RedisCluster{},
        builder.WithPredicates(predicate.GenerationChangedPredicate{})).
    Owns(&appsv1.Deployment{}).
    Complete(r)

加上后,用户只改 label/annotation/status 不会触发 Reconcile,只有改 spec 才会。这对减少噪声非常有效。

3. Status 变化过滤

反过来,有时你想监听 status 变化(比如 Deployment 的 readyReplicas 从 0 变 3 时要更新 CR 的 status)。这时不能用 GenerationChangedPredicate,因为关联资源的 status 变化不会改 generation。

Owns 默认会监听关联资源的所有变化,包括 status。如果你想精细控制,可以用 Owns(&Deployment{}, builder.WithPredicates(...))

4. 自定义 Predicate

实现 predicate.Predicate 接口(嵌套 predicate.Funcs)即可:

go
import (
    "sigs.k8s.io/controller-runtime/pkg/event"
    "sigs.k8s.io/controller-runtime/pkg/predicate"
)

// 只在 spec.size 变化时触发
type SizeChangedPredicate struct {
    predicate.Funcs
}

func (SizeChangedPredicate) Update(e event.UpdateEvent) bool {
    oldRC, ok := e.ObjectOld.(*cachev1.RedisCluster)
    if !ok {
        return false
    }
    newRC, ok := e.ObjectNew.(*cachev1.RedisCluster)
    if !ok {
        return false
    }
    return oldRC.Spec.Size != newRC.Spec.Size
}

// Funcs 默认实现:Create 返回 true,Delete 返回 true,Generic 返回 true
// 你只覆盖关心的方法即可

Predicate 接口有四个方法,对应四种事件:

方法触发时机
Create资源被创建
Delete资源被删除
Update资源被更新
Generic非集群事件(如 Channel 投递)

返回 true 表示放行,false 表示丢弃。

六、多资源 Watch:Watch ConfigMap、Secret

1. 为什么需要 Watch 外部资源

很多 Operator 依赖一些「不被 CR 拥有」的共享资源,比如:

  • 一个全局配置 ConfigMap,CR 引用它的名字。
  • 一个 TLS Secret,多个 CR 共用。
  • 一个镜像拉取 Secret。

当这些资源变化时,所有引用它的 CR 都该重新 Reconcile。但它们没有 ownerReferences 指向 CR,Owns 不管用,需要手动 Watches

2. Watches + EnqueueRequestsFromMapFunc

EnqueueRequestsFromMapFunc 把任意对象映射为一组 Reconcile 请求:

go
import (
    "sigs.k8s.io/controller-runtime/pkg/handler"
    "sigs.k8s.io/controller-runtime/pkg/reconcile"
)

func (r *RedisClusterReconciler) SetupWithManager(mgr ctrl.Manager) error {
    return ctrl.NewControllerManagedBy(mgr).
        For(&cachev1.RedisCluster{},
            builder.WithPredicates(predicate.GenerationChangedPredicate{})).
        Owns(&appsv1.Deployment{}).
        Owns(&corev1.Service{}).
        // 额外 Watch ConfigMap:当配置变化时触发引用它的所有 CR
        Watches(
            &corev1.ConfigMap{},
            handler.EnqueueRequestsFromMapFunc(r.configMapToRedisRequests),
        ).
        Complete(r)
}

// 把 ConfigMap 变化映射为引用它的 RedisCluster 的 Reconcile 请求
func (r *RedisClusterReconciler) configMapToRedisRequests(ctx context.Context, obj client.Object) []reconcile.Request {
    cm := obj.(*corev1.ConfigMap)

    // 通过 label 找出所有引用这个 ConfigMap 的 RedisCluster
    var list cachev1.RedisClusterList
    if err := r.List(ctx, &list, client.InNamespace(cm.Namespace)); err != nil {
        return nil
    }

    var requests []reconcile.Request
    for _, rc := range list.Items {
        if rc.Spec.ConfigMapName != nil && *rc.Spec.ConfigMapName == cm.Name {
            requests = append(requests, reconcile.Request{
                NamespacedName: types.NamespacedName{
                    Name:      rc.Name,
                    Namespace: rc.Namespace,
                },
            })
        }
    }
    return requests
}

注意:MapFunc 返回的是「Reconcile 请求列表」,不是一个。因为一个 ConfigMap 可能被多个 CR 引用,变化时要触发所有引用者的 Reconcile。

3. Watch 的 Predicate

Watch 的资源也可以加 Predicate。比如 ConfigMap 变化频繁但你只关心 data 变化:

go
Watches(
    &corev1.ConfigMap{},
    handler.EnqueueRequestsFromMapFunc(r.configMapToRedisRequests),
    builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}),
)

4. 多资源 Watch 的风险

Watch 越多资源,Cache 占用越大、Reconcile 触发越频繁。原则:

  • 只 Watch 你真正需要响应的资源。
  • 给每个 Watch 都加合适的 Predicate。
  • MapFunc 里尽量用 List + label,避免全量遍历。

七、Reconcile 常见错误处理

1. 冲突错误(Conflict)

Update 时如果对象的 resourceVersion 已经变了(别人先更新了),API Server 返回 409 Conflict。处理方式:

go
import "k8s.io/apimachinery/pkg/api/errors"

if err := r.Update(ctx, dep); err != nil {
    if errors.IsConflict(err) {
        // 重新 Get 再试,或直接返回让框架重试
        return ctrl.Result{Requeue: true}, nil
    }
    return ctrl.Result{}, err
}

更优雅的做法是用 retry.RetryOnConflict

go
import "k8s.io/client-go/util/retry"

err := retry.RetryOnConflict(retry.DefaultRetry, func() error {
    // 每次重试都重新 Get 最新版本
    if err := r.Get(ctx, key, dep); err != nil {
        return err
    }
    dep.Spec.Replicas = &newSize
    return r.Update(ctx, dep)
})

2. NotFound 错误

资源被并发删除时 Get 会返回 NotFound。多数情况下这不是错误——比如 Pod 被删了,你只需忽略并继续:

go
if err := r.Get(ctx, key, &pod); err != nil {
    if errors.IsNotFound(err) {
        // 资源不在了,按"需要创建"处理
        return r.Create(ctx, newPod)
    }
    return err
}

3. 不要在 Reconcile 里 panic

任何 panic 都会导致整个 Controller 进程崩溃(即使有 recover 也会让这次 Reconcile 失败且不重试)。所有可能出错的外部调用都要检查 error 并返回,不要用 must 风格。

八、完整示例:带 ConfigMap 的应用部署 Operator

下面给出一个完整的小示例,把本篇讲的所有点串起来:一个 AppDeployment Operator,它管理一个 Deployment,Deployment 的环境变量从一个引用的 ConfigMap 读取,ConfigMap 变化时自动滚动重启。

1. API 定义

go
package v1

import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"

type AppDeploymentSpec struct {
    // +kubebuilder:validation:Required
    Image string `json:"image"`
    // +kubebuilder:validation:Minimum=1
    // +kubebuilder:default=1
    Replicas int32 `json:"replicas"`
    // ConfigMapName 指定配置来源,变化时触发滚动更新
    ConfigMapName string `json:"configMapName,omitempty"`
}

type AppDeploymentStatus struct {
    ReadyReplicas int32  `json:"readyReplicas,omitempty"`
    Phase         string `json:"phase,omitempty"`
}

// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
type AppDeployment struct {
    metav1.TypeMeta   `json:",inline"`
    metav1.ObjectMeta `json:"metadata,omitempty"`
    Spec   AppDeploymentSpec   `json:"spec,omitempty"`
    Status AppDeploymentStatus `json:"status,omitempty"`
}

// +kubebuilder:object:root=true
type AppDeploymentList struct {
    metav1.TypeMeta `json:",inline"`
    metav1.ListMeta `json:"metadata,omitempty"`
    Items           []AppDeployment `json:"items"`
}

func init() {
    SchemeBuilder.Register(&AppDeployment{}, &AppDeploymentList{})
}

2. Reconcile 实现

go
package controller

import (
    "context"
    "crypto/sha256"
    "encoding/hex"
    "fmt"

    appsv1 "k8s.io/api/apps/v1"
    corev1 "k8s.io/api/core/v1"
    "k8s.io/apimachinery/pkg/api/errors"
    metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    "k8s.io/apimachinery/pkg/runtime"
    "k8s.io/apimachinery/pkg/types"
    "k8s.io/apimachinery/pkg/util/intstr"
    ctrl "sigs.k8s.io/controller-runtime"
    "sigs.k8s.io/controller-runtime/pkg/builder"
    "sigs.k8s.io/controller-runtime/pkg/client"
    "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
    "sigs.k8s.io/controller-runtime/pkg/handler"
    "sigs.k8s.io/controller-runtime/pkg/log"
    "sigs.k8s.io/controller-runtime/pkg/predicate"
    "sigs.k8s.io/controller-runtime/pkg/reconcile"

    appv1 "example.com/app-operator/api/v1"
)

type AppDeploymentReconciler struct {
    client.Client
    Scheme *runtime.Scheme
}

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

    // 1. 获取 CR
    var app appv1.AppDeployment
    if err := r.Get(ctx, req.NamespacedName, &app); err != nil {
        if errors.IsNotFound(err) {
            return ctrl.Result{}, nil
        }
        return ctrl.Result{}, err
    }

    // 2. 读取引用的 ConfigMap,计算其内容的哈希
    configHash := ""
    if app.Spec.ConfigMapName != "" {
        var cm corev1.ConfigMap
        if err := r.Get(ctx, types.NamespacedName{
            Name: app.Spec.ConfigMapName, Namespace: app.Namespace,
        }, &cm); err != nil {
            if errors.IsNotFound(err) {
                logger.Info("引用的 ConfigMap 不存在", "cm", app.Spec.ConfigMapName)
                // 稍后再试
                return ctrl.Result{RequeueAfter: 30}, nil
            }
            return ctrl.Result{}, err
        }
        configHash = hashConfigMap(&cm)
    }

    // 3. 调谐 Deployment(把 configHash 注入到 annotation,变化时触发滚动)
    needRequeue, err := r.reconcileDeployment(ctx, &app, configHash)
    if err != nil {
        return ctrl.Result{}, err
    }

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

    if needRequeue {
        return ctrl.Result{RequeueAfter: 10}, nil
    }
    return ctrl.Result{}, nil
}

func (r *AppDeploymentReconciler) reconcileDeployment(ctx context.Context, app *appv1.AppDeployment, configHash string) (bool, error) {
    logger := log.FromContext(ctx)
    labels := map[string]string{
        "app.kubernetes.io/instance":   app.Name,
        "app.kubernetes.io/managed-by": "app-operator",
    }

    var dep appsv1.Deployment
    key := types.NamespacedName{Name: app.Name, Namespace: app.Namespace}

    // 用 CreateOrUpdate 模式
    _, err := controllerutil.CreateOrUpdate(ctx, r.Client, &dep, func() error {
        if dep.ObjectMeta.CreationTimestamp.IsZero() {
            // 新建:设置 owner
            if err := controllerutil.SetControllerReference(app, &dep, r.Scheme); err != nil {
                return err
            }
        }
        dep.ObjectMeta.Labels = labels
        // 把 configHash 放进 pod template annotation,变化时触发滚动
        if dep.Spec.Template.ObjectMeta.Annotations == nil {
            dep.Spec.Template.ObjectMeta.Annotations = map[string]string{}
        }
        dep.Spec.Template.ObjectMeta.Annotations["config-hash"] = configHash

        replicas := app.Spec.Replicas
        dep.Spec.Replicas = &replicas
        dep.Spec.Selector = &metav1.LabelSelector{MatchLabels: labels}
        dep.Spec.Template.ObjectMeta.Labels = labels
        dep.Spec.Template.Spec.Containers = []corev1.Container{{
            Name:  "app",
            Image: app.Spec.Image,
            EnvFrom: func() []corev1.EnvFromSource {
                if app.Spec.ConfigMapName == "" {
                    return nil
                }
                return []corev1.EnvFromSource{{
                    ConfigMapRef: &corev1.ConfigMapEnvSource{
                        LocalObjectReference: corev1.LocalObjectReference{Name: app.Spec.ConfigMapName},
                    },
                }}
            }(),
        }}
        return nil
    })
    if err != nil {
        return false, err
    }

    // 判断就绪
    if dep.Status.ReadyReplicas != app.Spec.Replicas {
        logger.Info("Deployment 尚未就绪", "ready", dep.Status.ReadyReplicas, "desired", app.Spec.Replicas)
        return true, nil
    }
    return false, nil
}

func (r *AppDeploymentReconciler) updateStatus(ctx context.Context, app *appv1.AppDeployment) error {
    var dep appsv1.Deployment
    if err := r.Get(ctx, types.NamespacedName{Name: app.Name, Namespace: app.Namespace}, &dep); err != nil {
        return err
    }

    phase := "Pending"
    if dep.Status.ReadyReplicas == app.Spec.Replicas && app.Spec.Replicas > 0 {
        phase = "Running"
    }

    if app.Status.Phase != phase || app.Status.ReadyReplicas != dep.Status.ReadyReplicas {
        base := app.DeepCopy()
        app.Status.Phase = phase
        app.Status.ReadyReplicas = dep.Status.ReadyReplicas
        return r.Status().Patch(ctx, app, client.MergeFrom(base))
    }
    return nil
}

// SetupWithManager 注册 Controller,Watch CR + Deployment + ConfigMap
func (r *AppDeploymentReconciler) SetupWithManager(mgr ctrl.Manager) error {
    return ctrl.NewControllerManagedBy(mgr).
        For(&appv1.AppDeployment{},
            builder.WithPredicates(predicate.GenerationChangedPredicate{})).
        Owns(&appsv1.Deployment{}).
        // Watch ConfigMap:变化时把引用它的所有 AppDeployment 入队
        Watches(
            &corev1.ConfigMap{},
            handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, obj client.Object) []reconcile.Request {
                cm := obj.(*corev1.ConfigMap)
                var list appv1.AppDeploymentList
                if err := r.List(ctx, &list, client.InNamespace(cm.Namespace)); err != nil {
                    return nil
                }
                var reqs []reconcile.Request
                for i := range list.Items {
                    if list.Items[i].Spec.ConfigMapName == cm.Name {
                        reqs = append(reqs, reconcile.Request{
                            NamespacedName: types.NamespacedName{
                                Name: list.Items[i].Name, Namespace: list.Items[i].Namespace,
                            },
                        })
                    }
                }
                return reqs
            }),
        ).
        Complete(r)
}

// hashConfigMap 计算 ConfigMap data 的哈希,用于检测变化
func hashConfigMap(cm *corev1.ConfigMap) string {
    h := sha256.New()
    fmt.Fprintln(h, cm.ResourceVersion)
    for k, v := range cm.Data {
        fmt.Fprintf(h, "%s=%s\n", k, v)
    }
    return hex.EncodeToString(h.Sum(nil))[:16]
}

var _ = intstr.FromInt

3. 滚动更新的原理

关键设计在 config-hash annotation:每次 Reconcile 都计算 ConfigMap 内容的哈希,写入 Pod template 的 annotation。Pod template 任何字段变化(包括 annotation)都会触发 Deployment 滚动更新。这样 ConfigMap 一改,下次 Reconcile 时 hash 变了,Pod template 变了,Deployment 自动滚动重启。

九、小结

本篇深入 Reconcile 循环的方方面面。要点回顾:

  1. 返回值四种语义Result{}/RequeueAfter/error 组合表达「完成/待观察/出错重试/永久失败」。生产代码用 RequeueAfter 做周期观察,用 error 让框架重试。
  2. 五步工作流:Get CR → 比对状态 → 消除差异 → 更新 Status → 返回结果。所有 Operator 都遵循这个骨架。
  3. Owner Reference 实现集群内资源的级联删除。用 SetControllerReference 在 Create 前设置。外部资源清理要用 Finalizer。
  4. CreateOrUpdate 适合简单资源;Service 等有不可变字段的资源要手动 Get+比较+Update。Patch + MergeFrom 是高并发场景首选。
  5. Predicate 是减少无效 Reconcile 的利器。主资源加 GenerationChangedPredicate,关联资源按需过滤。
  6. 多资源 WatchWatches + EnqueueRequestsFromMapFunc,把外部资源变化映射为 CR 的 Reconcile 请求。注意加 Predicate。
  7. 错误处理:Conflict 用 RetryOnConflict,NotFound 通常忽略,永不 panic。
  8. 滚动更新技巧:把依赖资源(ConfigMap)的哈希写进 Pod template annotation,依赖变化即触发滚动。

下一篇我们专注 Status 管理,讲清楚 Conditions 模式、Phase vs Condition、ObservedGeneration,把本篇的简单 phase 升级为生产级的状态报告。