Skip to content

状态管理与 Status 子资源

本篇聚焦 Operator 的「状态报告」能力——Status。前面几章我们用了一个简单的 phase 字段表示集群阶段,但生产级 Operator 需要更精细的状态表达。本篇会讲清楚 Status 子资源的作用、Conditions 模式、Phase vs Condition 的取舍、ObservedGeneration 的含义,以及如何避免 Status 更新引起的无限循环。最后给出一个生产级的 RedisCluster 状态管理实现。

一、Status 子资源的作用

1. 为什么要把 Spec 和 Status 分开

K8s 资源对象天然分成两部分:

  • Spec:用户声明的「期望状态」,由用户写入。
  • Status:控制器报告的「实际状态」,由控制器写入。

这种分离有深刻的设计意义:

  1. 权限隔离:可以给用户写 Spec 的权限,但不给写 Status 的权限(防止用户伪造状态)。RBAC 里 redisclustersredisclusters/status 是两个独立资源,可分别授权。
  2. 避免无限循环:Status 是控制器写的,如果 Status 变化也触发 Reconcile,控制器写 Status → 触发 Reconcile → 又写 Status → 又触发……死循环。子资源机制让 Status 变化不触发本资源的 Reconcile。
  3. 审计清晰:Spec 是「意图」,Status 是「事实」,分开后审计日志一目了然。

2. 启用 Status 子资源

在 CRD 类型上加 +kubebuilder:subresource:status 标记:

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

make manifests 后,CRD YAML 末尾会多出:

yaml
versions:
  - name: v1
    ...
    subresources:
      status: {}

3. Status 子资源的行为

启用后,API Server 对该资源的行为改变:

操作不开子资源开了 status 子资源
PUT /redisclusters/x(含 status)可改 status忽略 status,只能改 spec/metadata
PUT /redisclusters/x/status不存在该路径专门改 status 的端点
status 变化触发 Reconcile不会

也就是说,开了子资源后:

  • 普通的 Update(客户端 r.Update不能改 status,API Server 会静默忽略 status 字段。
  • 必须用 r.Status().Update()r.Status().Patch() 才能改 status。
  • Status 变化不会触发该 CR 自己的 Reconcile(但仍可能触发别的 Controller 的 Watch)。

4. Status 更新不触发 Reconcile 的意义

这是子资源最重要的好处。考虑这个场景:

  1. Operator 把 status.phasePending 改成 Running
  2. 如果 status 变化触发 Reconcile,Controller 立刻再次进入 Reconcile。
  3. Reconcile 发现状态没变(还是 Running),不写 status,返回。
  4. 没有新事件,循环停止。

看起来没问题?但如果你的 Reconcile 里有「乐观更新」逻辑(每次都写 status 即使没变),就会无限循环。子资源机制从根上避免了这一点——status 变化根本不进队列。

不过要注意:status 变化虽然不触发本 CR 的 Reconcile,但如果别的 Controller Watch 了这个 CR,它仍然会收到 status 变化事件。所以你的 Reconcile 写 status 时仍要判断「是否真的变了」,避免给别的 Controller 制造噪声。

二、Conditions 模式

1. 为什么 Phase 不够用

phase 是一个单一字符串,比如 Pending / Running / Failed。它简单直观,但表达能力有限:

  • 一个集群可能同时处于多个状态:Deployment 就绪了,但备份还没完成。Phase 只能选一个,丢失了「备份中」这个信息。
  • Phase 变化是「覆盖式」的,历史信息丢失。从 RunningFailed 再变回 Running,你不知道中间发生过什么。
  • Phase 的语义靠人为约定,不同 Operator 的 Running 含义可能不同,缺乏标准化。

Conditions 模式就是为了解决这些问题——用一个 Condition 列表,每个 Condition 描述一个独立维度的状态。

2. Condition 的结构

K8s 社区约定 Condition 结构(来自 metav1.Condition):

go
type Condition struct {
    // Type 是条件类型,如 Ready、Progressing、Available
    Type string `json:"type"`
    // Status 是该条件的状态:True / False / Unknown
    Status metav1.ConditionStatus `json:"status"`
    // ObservedGeneration 是控制器观察到的 spec.generation
    ObservedGeneration int64 `json:"observedGeneration,omitempty"`
    // LastTransitionTime 是上次 status 变化的时间
    LastTransitionTime metav1.Time `json:"lastTransitionTime"`
    // Reason 是机器可读的简短原因码,驼峰大写
    Reason string `json:"reason"`
    // Message 是人类可读的详细说明
    Message string `json:"message,omitempty"`
}

一个 CR 的 status 里维护一个 Conditions 列表:

go
type RedisClusterStatus struct {
    Phase      string             `json:"phase,omitempty"`
    Conditions []metav1.Condition `json:"conditions,omitempty"`
    Nodes      []string           `json:"nodes,omitempty"`
}

3. Condition 类型设计

Type 是 Condition 的标识,用驼峰大写。常见的 Condition Type:

Type含义True 时机
Ready整体就绪,可对外服务所有关键条件都满足
Available服务可用(有足够就绪副本)ReadyReplicas >= 1
Progressing正在变更中(创建、扩缩、升级)有未完成的滚动操作
Degraded降级(部分功能不可用但整体仍运行)某些副本不健康
BackupRunning业务特定:备份进行中备份任务运行中

设计原则:

  • 正交:每个 Type 描述一个独立维度,不要重叠。
  • 可观测:选那些用户/监控系统关心的状态。
  • 稳定:Type 名字一旦发布就不要改,否则破坏兼容性。

4. Condition 状态:True/False/Unknown

  • True:该条件成立(如 Ready=True 表示就绪)。
  • False:该条件不成立(如 Ready=False 表示未就绪)。
  • Unknown:控制器还没观察到(如刚启动、或与 API Server 失联)。

注意语义:Ready=False 不等于「出错」,它只表示「当前未就绪」,可能是正常的启动过程。错误信息靠 ReasonMessage 表达。

5. Condition 历史记录

Condition 自身只记录「最后一次状态变化时间」(LastTransitionTime),不记录历史。如果要保留历史,需要自己在 Status 里维护一个 events 列表,或依赖 K8s Event 资源。

K8s 内置的 Event 资源(kubectl get events)天然适合记录状态变化的「日志」,建议把「发生了什么」记进 Event,把「现在是什么状态」记进 Condition。

go
import "k8s.io/client-go/tools/record"

type RedisClusterReconciler struct {
    client.Client
    Scheme   *runtime.Scheme
    Recorder record.EventRecorder
}

// 在状态变化时记录事件
r.Recorder.Eventf(rc, corev1.EventTypeNormal, "PhaseChanged",
    "Phase changed from %s to %s", oldPhase, newPhase)

三、Phase 模式 vs Condition 模式

1. 各自的定位

维度PhaseConditions
表达能力单一维度多维度,正交
历史信息仅最后变化时间
易用性高(kubectl 看 phase 列)中(需解析 conditions 数组)
标准化弱(各家自定义)较强(社区约定 Type/Status)
监控集成简单(按 phase 标签告警)灵活(按 condition 查询)

2. 实践建议

两者并用,Phase 做高层摘要,Conditions 做细粒度诊断:

  • Phase 给「一眼看懂」的场景:kubectl get rc 直接看 phase 列。
  • Conditions 给「深入诊断」的场景:某个集群 phase 是 Failed,看 Conditions 知道是 Available=False(副本不足)还是 BackupFailed=True(备份失败)。

Phase 由 Conditions 推导出来,而不是独立维护,避免不一致:

go
func computePhase(conditions []metav1.Condition) string {
    ready := getCondition(conditions, "Ready")
    progressing := getCondition(conditions, "Progressing")
    degraded := getCondition(conditions, "Degraded")

    if ready.Status == metav1.ConditionTrue {
        if degraded.Status == metav1.ConditionTrue {
            return "Degraded"
        }
        return "Running"
    }
    if progressing.Status == metav1.ConditionTrue {
        return "Progressing"
    }
    return "Pending"
}

四、ObservedGeneration

1. 什么是 Generation

metadata.generation 是 K8s 维护的整数计数器,仅在 spec 变化时递增(metadata/label/annotation/status 变化不递增)。它本质上是「spec 的版本号」。

2. ObservedGeneration 的作用

status.observedGeneration 是控制器写入的,表示「我处理到的 spec.generation 是多少」。用户对比这两个值就能知道控制器是否处理了最新的 spec:

  • metadata.generation == status.observedGeneration:控制器已处理最新 spec。
  • metadata.generation > status.observedGeneration:控制器还没处理最新 spec(spec 改了但还没 Reconcile)。

3. 为什么重要

考虑这个场景:

  1. 用户把 spec.size 从 3 改成 5,generation 变成 2。
  2. Operator 正在 Reconcile 别的 CR,还没轮到这个。
  3. 用户 kubectl get rc 看到 status.readyReplicas=3, phase=Running
  4. 用户误以为「已经扩到 5 了,且 3 个就绪」——其实 Operator 根本没开始处理。

如果 status 里有 observedGeneration=1,用户就能发现「generation=2 但 observedGeneration=1,控制器还没处理最新 spec」,从而正确等待。

4. 如何设置

在更新 status 时,把当前的 metadata.generation 写进 observedGeneration

go
func (r *RedisClusterReconciler) updateStatus(ctx context.Context, rc *cachev1.RedisCluster) error {
    base := rc.DeepCopy()

    // 设置 observedGeneration
    rc.Status.ObservedGeneration = rc.Generation

    // ... 计算 conditions、phase ...

    return r.Status().Patch(ctx, rc, client.MergeFrom(base))
}

更好的做法是用 +kubebuilder:default 标记让 CRD schema 要求这个字段,并在每个 Condition 里也带上 ObservedGeneration(表示「这个 condition 是基于哪个 spec 版本评估的」)。

五、Status 更新最佳实践

1. 使用 Status().Patch()

第四章讲过,Patch 优于 Update。对 Status 更是如此——Status 更新频繁,Patch 只发 diff,冲突少。

go
base := rc.DeepCopy()
// 修改 status
rc.Status.Phase = "Running"
rc.Status.ObservedGeneration = rc.Generation
// 用 base 做 merge 基准
return r.Status().Patch(ctx, rc, client.MergeFrom(base))

MergeFrom(base) 会自动算出 base 到当前对象的 diff,只 patch 变化的字段。

2. 避免无限循环

虽然开了 status 子资源后 status 变化不触发本 CR Reconcile,但仍要遵循「变了才写」原则,原因:

  • 减少 API 调用(每次 Patch 都是 API Server 负载)。
  • 避免给别的 Watch 这个 CR 的 Controller 制造噪声。
  • 减少 etcd 写入(etcd 写入有性能上限)。

实现方式:维护一个「期望 status」结构,与当前 status 比对,不同才 Patch。

go
func (r *RedisClusterReconciler) updateStatus(ctx context.Context, rc *cachev1.RedisCluster, desired cachev1.RedisClusterStatus) error {
    if statusEqual(rc.Status, desired) {
        return nil  // 没变,不写
    }
    base := rc.DeepCopy()
    rc.Status = desired
    return r.Status().Patch(ctx, rc, client.MergeFrom(base))
}

3. 状态更新与资源调谐分开

不要在「创建 Deployment」和「更新 Status」之间穿插,否则容易因为 Status 更新失败导致部分资源已变更但状态没记录。推荐结构:

1. 调谐所有资源(Deployment/Service/...)
2. 收集实际状态
3. 一次性更新 Status

4. 乐观并发与重试

Status 更新可能因冲突失败(别人也改了)。用 RetryOnConflict 重试:

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

err := retry.RetryOnConflict(retry.DefaultBackoff, func() error {
    // 重新 Get 最新版本
    var latest cachev1.RedisCluster
    if err := r.Get(ctx, req.NamespacedName, &latest); err != nil {
        return err
    }
    base := latest.DeepCopy()
    // 计算新的 status
    latest.Status = computeDesiredStatus(&latest)
    // 只在确实变化时 patch
    if statusEqual(base.Status, latest.Status) {
        return nil
    }
    return r.Status().Patch(ctx, &latest, client.MergeFrom(base))
})

六、完整的状态管理实现

下面给出一个生产级的 Status 管理工具集,适用于任何 Operator。

1. 定义 Conditions 类型

直接复用 metav1.Condition,并写一组辅助函数:

go
package controller

import (
    "fmt"
    "time"

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

// Condition 类型常量
const (
    ConditionReady       = "Ready"
    ConditionAvailable   = "Available"
    ConditionProgressing = "Progressing"
    ConditionDegraded    = "Degraded"
)

// Condition 原因常量
const (
    ReasonReconciling      = "Reconciling"
    ReasonReady            = "AllReplicasReady"
    ReasonPartiallyReady   = "PartialReplicasReady"
    ReasonNoReplicas       = "NoReplicasReady"
    ReasonScaling          = "Scaling"
    ReasonUpdating         = "UpdatingImage"
)

// setCondition 设置或更新一个 condition
// 如果同 Type 的 condition 状态没变,只更新时间戳以外的字段
func setCondition(conditions []metav1.Condition, newCond metav1.Condition) []metav1.Condition {
    for i, c := range conditions {
        if c.Type == newCond.Type {
            if c.Status == newCond.Status {
                // 状态没变,保留 LastTransitionTime
                newCond.LastTransitionTime = c.LastTransitionTime
            }
            conditions[i] = newCond
            return conditions
        }
    }
    // 不存在该 Type,追加
    if newCond.LastTransitionTime.IsZero() {
        newCond.LastTransitionTime = metav1.NewTime(time.Now())
    }
    return append(conditions, newCond)
}

// getCondition 取指定 Type 的 condition
func getCondition(conditions []metav1.Condition, condType string) metav1.Condition {
    for _, c := range conditions {
        if c.Type == condType {
            return c
        }
    }
    return metav1.Condition{
        Type:   condType,
        Status: metav1.ConditionUnknown,
        Reason: "NotObserved",
    }
}

// removeCondition 删除指定 Type 的 condition
func removeCondition(conditions []metav1.Condition, condType string) []metav1.Condition {
    for i, c := range conditions {
        if c.Type == condType {
            return append(conditions[:i], conditions[i+1:]...)
        }
    }
    return conditions
}

2. 计算 Conditions

针对 RedisCluster 的状态计算逻辑:

go
// computeStatus 根据实际资源情况计算期望的 Status
func (r *RedisClusterReconciler) computeStatus(ctx context.Context, rc *cachev1.RedisCluster) cachev1.RedisClusterStatus {
    // 查询关联 Deployment
    var dep appsv1.Deployment
    err := r.Get(ctx, types.NamespacedName{Name: rc.Name, Namespace: rc.Namespace}, &dep)

    status := cachev1.RedisClusterStatus{
        ObservedGeneration: rc.Generation,
    }

    if err != nil && errors.IsNotFound(err) {
        // Deployment 不存在,所有条件未就绪
        status.Conditions = setCondition(status.Conditions, metav1.Condition{
            Type:    ConditionReady,
            Status:  metav1.ConditionFalse,
            Reason:  ReasonNoReplicas,
            Message: "Deployment not created yet",
            ObservedGeneration: rc.Generation,
        })
        status.Conditions = setCondition(status.Conditions, metav1.Condition{
            Type:    ConditionProgressing,
            Status:  metav1.ConditionTrue,
            Reason:  ReasonReconciling,
            Message: "Creating deployment",
            ObservedGeneration: rc.Generation,
        })
        status.Phase = "Pending"
        return status
    }
    if err != nil {
        status.Conditions = setCondition(status.Conditions, metav1.Condition{
            Type:    ConditionReady,
            Status:  metav1.ConditionUnknown,
            Reason:  "GetFailed",
            Message: err.Error(),
        })
        status.Phase = "Unknown"
        return status
    }

    // 收集 Pod 名
    var pods corev1.PodList
    r.List(ctx, &pods, client.InNamespace(rc.Namespace),
        client.MatchingLabels{"app.kubernetes.io/instance": rc.Name})
    for _, p := range pods.Items {
        status.Nodes = append(status.Nodes, p.Name)
    }

    desired := rc.Spec.Size
    ready := dep.Status.ReadyReplicas

    status.ReadyReplicas = ready

    // Available: 至少 1 个就绪
    if ready >= 1 {
        status.Conditions = setCondition(status.Conditions, metav1.Condition{
            Type:    ConditionAvailable,
            Status:  metav1.ConditionTrue,
            Reason:  ReasonPartiallyReady,
            Message: fmt.Sprintf("%d/%d replicas ready", ready, desired),
            ObservedGeneration: rc.Generation,
        })
    } else {
        status.Conditions = setCondition(status.Conditions, metav1.Condition{
            Type:    ConditionAvailable,
            Status:  metav1.ConditionFalse,
            Reason:  ReasonNoReplicas,
            Message: "No ready replicas",
            ObservedGeneration: rc.Generation,
        })
    }

    // Progressing: 还在扩缩/更新
    if ready != desired || dep.Status.UpdatedReplicas != desired {
        status.Conditions = setCondition(status.Conditions, metav1.Condition{
            Type:    ConditionProgressing,
            Status:  metav1.ConditionTrue,
            Reason:  ReasonScaling,
            Message: fmt.Sprintf("Scaling to %d, %d ready", desired, ready),
            ObservedGeneration: rc.Generation,
        })
    } else {
        status.Conditions = setCondition(status.Conditions, metav1.Condition{
            Type:    ConditionProgressing,
            Status:  metav1.ConditionFalse,
            Reason:  "Reconciled",
            Message: "All replicas ready and up-to-date",
            ObservedGeneration: rc.Generation,
        })
    }

    // Ready: 所有副本就绪且无更新进行中
    if ready == desired && ready > 0 {
        status.Conditions = setCondition(status.Conditions, metav1.Condition{
            Type:    ConditionReady,
            Status:  metav1.ConditionTrue,
            Reason:  ReasonReady,
            Message: fmt.Sprintf("%d replicas ready", ready),
            ObservedGeneration: rc.Generation,
        })
        status.Phase = "Running"
    } else if ready > 0 {
        status.Conditions = setCondition(status.Conditions, metav1.Condition{
            Type:    ConditionReady,
            Status:  metav1.ConditionFalse,
            Reason:  ReasonPartiallyReady,
            Message: fmt.Sprintf("%d/%d ready", ready, desired),
            ObservedGeneration: rc.Generation,
        })
        status.Phase = "Progressing"
    } else {
        status.Conditions = setCondition(status.Conditions, metav1.Condition{
            Type:    ConditionReady,
            Status:  metav1.ConditionFalse,
            Reason:  ReasonNoReplicas,
            Message: "No ready replicas",
            ObservedGeneration: rc.Generation,
        })
        status.Phase = "Pending"
    }

    return status
}

3. 应用 Status

go
func (r *RedisClusterReconciler) reconcileStatus(ctx context.Context, rc *cachev1.RedisCluster) error {
    desired := r.computeStatus(ctx, rc)

    if statusEqual(rc.Status, desired) {
        return nil
    }

    base := rc.DeepCopy()
    rc.Status = desired
    if err := r.Status().Patch(ctx, rc, client.MergeFrom(base)); err != nil {
        return client.IgnoreNotFound(err)
    }
    return nil
}

// statusEqual 比较两个 Status 是否一致(忽略 LastTransitionTime 的细微差异)
func statusEqual(a, b cachev1.RedisClusterStatus) bool {
    if a.Phase != b.Phase {
        return false
    }
    if a.ObservedGeneration != b.ObservedGeneration {
        return false
    }
    if a.ReadyReplicas != b.ReadyReplicas {
        return false
    }
    if !sameStringSlice(a.Nodes, b.Nodes) {
        return false
    }
    if len(a.Conditions) != len(b.Conditions) {
        return false
    }
    // 比较 conditions(忽略 LastTransitionTime)
    for i := range a.Conditions {
        if a.Conditions[i].Type != b.Conditions[i].Type ||
            a.Conditions[i].Status != b.Conditions[i].Status ||
            a.Conditions[i].Reason != b.Conditions[i].Reason {
            return false
        }
    }
    return true
}

4. 整合进 Reconcile

go
func (r *RedisClusterReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    var rc cachev1.RedisCluster
    if err := r.Get(ctx, req.NamespacedName, &rc); err != nil {
        return ctrl.Result{}, client.IgnoreNotFound(err)
    }

    // 调谐资源
    needRequeue, err := r.reconcileDeployment(ctx, &rc)
    if err != nil {
        return ctrl.Result{}, err
    }
    if err := r.reconcileService(ctx, &rc); err != nil {
        return ctrl.Result{}, err
    }

    // 更新 status
    if err := r.reconcileStatus(ctx, &rc); err != nil {
        return ctrl.Result{}, err
    }

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

七、完整示例:RedisCluster 的状态管理

把上面的工具集用到第三章的 RedisCluster 上。先扩展 Status 类型:

go
type RedisClusterStatus struct {
    // ObservedGeneration 表示控制器已处理的 spec.generation
    ObservedGeneration int64 `json:"observedGeneration,omitempty"`

    // Phase 是高层状态摘要:Pending / Progressing / Running / Degraded / Failed
    Phase string `json:"phase,omitempty"`

    // Conditions 是细粒度状态列表
    Conditions []metav1.Condition `json:"conditions,omitempty"`

    // Nodes 是实际 Pod 名列表
    Nodes []string `json:"nodes,omitempty"`

    // ReadyReplicas 是就绪副本数
    ReadyReplicas int32 `json:"readyReplicas,omitempty"`
}

部署后查询:

bash
$ kubectl get rc my-redis -o jsonpath='{.status}'
{
  "observedGeneration": 2,
  "phase": "Running",
  "conditions": [
    {"type":"Ready","status":"True","reason":"AllReplicasReady","message":"3 replicas ready","observedGeneration":2},
    {"type":"Available","status":"True","reason":"PartiallyReady","observedGeneration":2},
    {"type":"Progressing","status":"False","reason":"Reconciled","observedGeneration":2}
  ],
  "nodes": ["my-redis-xxx-aaa","my-redis-xxx-bbb","my-redis-xxx-ccc"],
  "readyReplicas": 3
}

用 jq 看特定 condition:

bash
$ kubectl get rc my-redis -o json | jq '.status.conditions[] | select(.type=="Ready")'
{
  "type": "Ready",
  "status": "True",
  "reason": "AllReplicasReady",
  "message": "3 replicas ready",
  "observedGeneration": 2
}

监控告警可以基于 condition:

# Prometheus 告警规则示例
rediscluster_status{condition="Ready",status="False"} == 1

八、小结

本篇系统讲解了 Operator 的状态管理。要点回顾:

  1. Status 子资源+kubebuilder:subresource:status 开启。开启后普通 Update 不能改 status,必须用 Status().Update/Patch;且 status 变化不触发本 CR 的 Reconcile,从根上避免循环。
  2. Conditions 模式用多个正交的 Condition 描述多维状态,比单一 phase 表达力强。每个 Condition 有 Type/Status(True/False/Unknown)/Reason/Message/ObservedGeneration/LastTransitionTime。
  3. Phase 与 Conditions 并用:Phase 做高层摘要(kubectl 一眼看),Conditions 做细粒度诊断。Phase 由 Conditions 推导,不独立维护。
  4. ObservedGeneration 表示控制器已处理的 spec 版本,让用户能判断「status 是否反映最新 spec」。每次更新 status 都要设。
  5. 更新最佳实践:用 Status().Patch + MergeFrom(base);更新前比较,变了才写;资源调谐与 status 更新分离;冲突用 RetryOnConflict 重试。
  6. setCondition 工具函数:维护 condition 列表时,状态没变保留 LastTransitionTime,状态变了更新时间戳。这是社区标准做法。

下一篇讲 Finalizer——当级联删除不够用、需要清理外部资源时,Finalizer 是唯一的正确机制。