Appearance
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 秒后再来看看」。注意 RequeueAfter 和 Requeue 是两个字段:
RequeueAfter: time.Duration:固定间隔重新入队。Controller 会在 duration 后再次触发 Reconcile,即使期间没有任何事件。Requeue: true:立即重新入队。相当于「马上再来一次」。
两者同时设置时,RequeueAfter 优先。生产代码里绝大多数情况用 RequeueAfter,因为 Requeue: true 容易导致紧密循环打爆 API Server。
go
// 10 秒后再观察
return ctrl.Result{RequeueAfter: 10 * time.Second}, nil2. ctrl.Result{} + nil error
「这次处理完了,且当前期望状态已达成,不需要主动再次 Reconcile。等到下次有相关事件再叫我。」
这是「正常完成」的返回。Controller 不会再主动触发 Reconcile,只有当 Watch 的资源发生变化(比如用户改了 spec、关联 Pod 状态变化)才会再次进入。
go
// 一切就绪
return ctrl.Result{}, nil3. 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
}返回值决策表
| 场景 | Result | error |
|---|---|---|
| 期望状态已达成 | 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:
- K8s 给 RedisCluster 设置
deletionTimestamp。 - GC 控制器扫描所有 ownerReferences 指向它的对象。
- 把这些对象(Deployment、Service)逐个删除。
- 所有从属对象删完后,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。它的比较规则有两个坑:
- 默认字段会被认为是「变化」:比如
TypeMeta、Status这些字段在新建对象时是零值,DeepEqual 会判定为不同,导致每次都 Update。 - 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 合并。冲突更少,带宽更省。
| 维度 | Update | Patch |
|---|---|---|
| 网络开销 | 大(整个对象) | 小(仅变化字段) |
| 冲突概率 | 高(需最新 resourceVersion) | 低(按字段合并) |
| 并发友好 | 差 | 好 |
| 易用性 | 高(直接改对象) | 中(需构造 patch) |
controller-runtime Client 支持 Patch 和 PatchStatus:
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 用
Update或CreateOrUpdate(简单场景)或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.FromInt3. 滚动更新的原理
关键设计在 config-hash annotation:每次 Reconcile 都计算 ConfigMap 内容的哈希,写入 Pod template 的 annotation。Pod template 任何字段变化(包括 annotation)都会触发 Deployment 滚动更新。这样 ConfigMap 一改,下次 Reconcile 时 hash 变了,Pod template 变了,Deployment 自动滚动重启。
九、小结
本篇深入 Reconcile 循环的方方面面。要点回顾:
- 返回值四种语义:
Result{}/RequeueAfter/error组合表达「完成/待观察/出错重试/永久失败」。生产代码用RequeueAfter做周期观察,用error让框架重试。 - 五步工作流:Get CR → 比对状态 → 消除差异 → 更新 Status → 返回结果。所有 Operator 都遵循这个骨架。
- Owner Reference 实现集群内资源的级联删除。用
SetControllerReference在 Create 前设置。外部资源清理要用 Finalizer。 - CreateOrUpdate 适合简单资源;Service 等有不可变字段的资源要手动 Get+比较+Update。
Patch+MergeFrom是高并发场景首选。 - Predicate 是减少无效 Reconcile 的利器。主资源加
GenerationChangedPredicate,关联资源按需过滤。 - 多资源 Watch 用
Watches+EnqueueRequestsFromMapFunc,把外部资源变化映射为 CR 的 Reconcile 请求。注意加 Predicate。 - 错误处理:Conflict 用
RetryOnConflict,NotFound 通常忽略,永不 panic。 - 滚动更新技巧:把依赖资源(ConfigMap)的哈希写进 Pod template annotation,依赖变化即触发滚动。
下一篇我们专注 Status 管理,讲清楚 Conditions 模式、Phase vs Condition、ObservedGeneration,把本篇的简单 phase 升级为生产级的状态报告。