Kubernetes 基础体系 · 第 78/83 篇。示例基于 Kubernetes 当前稳定 API;弃用、版本偏差、云厂商差异和生产风险会明确说明。

Kubernetes Operator 模式:领域状态机、升级、备份、恢复和测试

Operator 是一种把“运行某类系统所需的领域知识”编码为 Kubernetes Controller 的模式。它通常包含两部分:

  • 自定义资源(Custom Resource,CR):用户声明系统的目标,例如数据库版本、实例数、备份策略。
  • 控制器(Controller):持续观察 CR、相关 Kubernetes 资源以及外部系统,把当前状态逐步推进到目标状态。

Kubernetes 本身只知道 Deployment、Service、Job、PersistentVolumeClaim 等通用资源的语义;它不知道“数据库如何安全升级”“恢复后如何验证 WAL 是否完整”“主从切换后哪个实例可以对外服务”。Operator 的价值不在于把 YAML 换成 Go,而在于把这些领域规则变成可重复执行、可观测、可恢复的控制逻辑。

本文以一个简化的 DatabaseCluster Operator 为例,讨论领域状态机、升级、备份、恢复和测试。示例使用 apiextensions.k8s.io/v1batch/v1apps/v1 等稳定 API。数据库备份、快照和高可用能力通常依赖具体数据库、CSI 驱动和云厂商,示例会明确区分 Kubernetes 规范保证与实现方责任。


一、Operator 的基本模型:声明、观察和收敛

1.1 Kubernetes 控制器解决的是什么问题

用户提交一个目标状态:

apiVersion: database.example.com/v1alpha1
kind: DatabaseCluster
metadata:
  name: orders
  namespace: data
spec:
  version: "16"
  replicas: 3
  storage:
    size: 500Gi
  backup:
    schedule: "0 2 * * *"
    retention: 7

这个对象只表达意图:

  • 期望数据库版本为 16
  • 期望有 3 个实例;
  • 期望持久化容量为 500Gi
  • 期望每天执行备份,并保留 7 份。

它没有直接表达:

  • 应该创建几个 StatefulSet;
  • 哪个实例是主实例;
  • 升级前是否必须有成功备份;
  • 恢复后如何验证数据一致性;
  • Pod 重启后如何重新发现主节点;
  • 删除 CR 时是否保留 PVC 和备份。

控制器通过以下过程工作:

Reconcile(D,O,E)A\text{Reconcile}(D, O, E) \rightarrow A

其中:

  • DD 是期望状态,通常来自 CR 的 spec
  • OO 是观察到的状态,包括缓存中的 Kubernetes 对象和外部数据库状态;
  • EE 是环境事实,例如 API 错误、网络故障、权限不足;
  • AA 是本轮要执行的动作集合,例如创建、更新、删除资源,或者重新排队。

控制器不是一次性脚本,而是反复计算:

On+1=Apply(On,An)O_{n+1} = \operatorname{Apply}(O_n, A_n)

当满足:

Equivalent(On,D)=true\operatorname{Equivalent}(O_n, D) = \text{true}

控制器才进入稳定状态。这里的 Equivalent 不是简单的对象完全相等。例如:

  • Deployment 的 PodTemplate 可能被 Kubernetes 注入字段;
  • Service 的 clusterIP 是服务器分配的;
  • StatefulSet 的实际 Pod 数可能暂时少于期望值;
  • 数据库虽然 Pod 为 Running,但 SQL 服务可能仍未就绪。

因此,Operator 判断的不是“字段是否完全相同”,而是“是否满足领域不变量”。

1.2 Reconcile 必须允许重复执行

控制器会因为很多原因再次被调用:

  • CR 的 spec 被修改;
  • 关联 Deployment、StatefulSet 或 Job 发生变化;
  • informer 收到更新事件;
  • API Server 暂时失败后重新排队;
  • 控制器重启;
  • leader election 发生切换;
  • 定时检查外部数据库状态。

所以 Reconcile 必须满足幂等性。若目标资源已经存在,第二次执行不应重复创建;若 Job 已经成功,第二次执行不应再次触发一次破坏性操作。

一个常见的控制器结构如下:

func (r *DatabaseClusterReconciler) Reconcile(
    ctx context.Context,
    req ctrl.Request,
) (ctrl.Result, error) {
    var cluster databasev1alpha1.DatabaseCluster
    if err := r.Get(ctx, req.NamespacedName, &cluster); err != nil {
        if apierrors.IsNotFound(err) {
            return ctrl.Result{}, nil
        }
        return ctrl.Result{}, err
    }

    if !cluster.DeletionTimestamp.IsZero() {
        return r.reconcileDelete(ctx, &cluster)
    }

    if err := r.ensureFinalizer(ctx, &cluster); err != nil {
        return ctrl.Result{}, err
    }

    if err := r.reconcileSpec(ctx, &cluster); err != nil {
        r.setFailureCondition(&cluster, err)
        _ = r.Status().Update(ctx, &cluster)
        return ctrl.Result{}, err
    }

    return ctrl.Result{RequeueAfter: 30 * time.Second}, nil
}

这段代码只展示控制流,真实实现还需要:

  1. 对状态更新使用独立的 Status().Update 或 patch;
  2. 处理并发更新导致的 Conflict
  3. 为关联资源设置 owner reference 或使用 label 关联;
  4. GetCreateUpdatePatch 的权限进行 RBAC 配置;
  5. 不把临时错误永久写成失败终态;
  6. 区分“需要重试的错误”和“用户必须修改 Spec 的错误”。

RequeueAfter 不是替代事件监听的机制。关联资源变化应通过 watch 触发;定时 requeue 主要用于外部系统没有 Kubernetes 事件,或者需要定期重新探测数据库健康状态的场景。


二、领域状态机:为什么不能只用 phase

2.1 状态机的形式化定义

领域状态机可以表示为:

M=(S,Σ,δ,s0,F)M = (S, \Sigma, \delta, s_0, F)

其中:

  • SS:有限状态集合;
  • Σ\Sigma:事件或观察结果集合;
  • δ:S×ΣS\delta: S \times \Sigma \rightarrow S:状态转移函数;
  • s0s_0:初始状态;
  • FF:满足某种业务目标的状态集合。

DatabaseCluster,可以定义:

Pending          尚未完成输入校验或基础资源准备
Provisioning     正在创建数据库拓扑
Ready            数据库满足服务条件
Upgrading        正在执行受控版本升级
BackupInProgress 正在执行备份
Restoring        正在恢复数据
Degraded         资源存在,但不满足服务不变量
Failed           需要人工修正或介入
Deleting         正在执行删除前清理

但状态不是控制器的唯一事实来源。状态只是对多个事实的压缩表达。更稳妥的状态结构是:

status:
  observedGeneration: 3
  phase: Ready
  conditions:
  - type: Available
    status: "True"
    reason: ClusterReady
    message: "3 instances are healthy"
    observedGeneration: 3
    lastTransitionTime: "2025-01-10T02:00:00Z"
  - type: Progressing
    status: "False"
    reason: ReconcileComplete
    observedGeneration: 3
  currentVersion: "16.2"
  targetVersion: "16.2"
  readyReplicas: 3
  backup:
    lastSuccessfulTime: "2025-01-10T02:00:00Z"
    location: "s3://backup-bucket/orders/..."

这些字段承担不同职责:

  • phase 适合给人快速浏览;
  • conditions 描述可组合的事实;
  • observedGeneration 表示控制器已经处理到哪个 spec 版本;
  • currentVersiontargetVersion 分离,避免把“目标已写入”误报成“实际已完成”;
  • readyReplicas 是观察值,不是期望值;
  • 备份信息必须包含可验证的成功事实,而不是“Job 已创建”。

2.2 迁移条件必须由不变量决定

例如,进入 Ready 至少需要满足:

I1:目标拓扑资源存在I2:数据库实例达到期望副本数I3:数据库协议探针成功I4:主从或共识关系满足领域要求I5:当前版本等于目标版本\begin{aligned} I_1 &: \text{目标拓扑资源存在} \\ I_2 &: \text{数据库实例达到期望副本数} \\ I_3 &: \text{数据库协议探针成功} \\ I_4 &: \text{主从或共识关系满足领域要求} \\ I_5 &: \text{当前版本等于目标版本} \end{aligned}

因此:

Ready    I1I2I3I4I5\text{Ready} \iff I_1 \land I_2 \land I_3 \land I_4 \land I_5

“Pod 为 Running”最多只能证明容器进程存活,不能推出数据库可写,更不能推出集群可恢复。

升级则要求更强的不变量:

CanUpgrade=ValidVersionPathBackupVerifiedTopologyHealthyNoConflictingOperation\text{CanUpgrade} = \text{ValidVersionPath} \land \text{BackupVerified} \land \text{TopologyHealthy} \land \text{NoConflictingOperation}

其中:

  • ValidVersionPath:数据库支持从当前版本到目标版本的升级路径;
  • BackupVerified:备份已完成且可被控制器识别;
  • TopologyHealthy:当前拓扑满足升级前置条件;
  • NoConflictingOperation:没有进行中的恢复、扩缩容或另一个升级。

如果控制器在 Ready 状态看到 spec.version 变化,应当先进入 Upgrading,而不是直接修改 StatefulSet 镜像并宣称升级成功。

2.3 一个完整的状态转换示例

假设初始对象为:

spec.version = 16
status.currentVersion = ""
status.phase = Pending

控制器按以下步骤推进:

  1. 校验 replicas >= 1、存储大小合法、版本格式可识别。
  2. 创建或修正 Secret、Service、PVC、StatefulSet。
  3. 观察 StatefulSet 副本是否就绪。
  4. 通过数据库探针确认 SQL 服务可连接。
  5. 读取数据库实际版本。
  6. 写入:
phase = Ready
currentVersion = 16
targetVersion = 16
Available=True

用户修改:

spec:
  version: "17"

此时 API Server 会递增 metadata.generation,例如从 3 变为 4。控制器应先观察到:

generation = 4
observedGeneration = 3
currentVersion = 16
targetVersion = 17

于是:

  1. 设置 Progressing=True
  2. 检查是否有适用于 16→17 的升级路径;
  3. 请求或验证升级前备份;
  4. 将版本变更动作写入可观察资源,例如 Job;
  5. 等待 Job 成功;
  6. 重新探测数据库实际版本;
  7. 只有实际版本变为 17 后,才写入:
phase = Ready
currentVersion = 17
targetVersion = 17
observedGeneration = 4
Available=True
Progressing=False

可以用下图表示:

stateDiagram-v2
    [*] --> Pending
    Pending --> Provisioning: 输入合法且基础资源未完成
    Provisioning --> Ready: 拓扑、探针、版本均满足
    Provisioning --> Degraded: 资源存在但不满足不变量
    Degraded --> Provisioning: 故障恢复或重试
    Ready --> Upgrading: targetVersion != currentVersion
    Upgrading --> Ready: 升级成功且实际版本匹配
    Upgrading --> Failed: 不可恢复的升级错误
    Ready --> BackupInProgress: 触发备份
    BackupInProgress --> Ready: 备份完成并验证
    BackupInProgress --> Degraded: 备份失败但服务仍可用
    Ready --> Restoring: 用户请求恢复
    Restoring --> Ready: 恢复、校验、探针均成功
    Restoring --> Failed: 恢复失败或数据不可验证
    Pending --> Deleting: 删除请求
    Provisioning --> Deleting: 删除请求
    Ready --> Deleting: 删除请求
    Failed --> Deleting: 删除请求
    Deleting --> [*]: 清理完成

图中的转移不是 Kubernetes 自动提供的能力,而是 Operator 自己实现的领域协议。控制器重启后必须能根据 CR、子资源、Job 结果和外部数据库状态重新推导当前节点;不能依赖只存在于进程内存中的状态变量。

2.4 反例:用 phase=Ready 代替事实

下面的逻辑是错误的:

if cluster.Status.Phase == "Ready" {
    cluster.Status.Phase = "Upgrading"
    updateStatefulSetImage(ctx, targetVersion)
}

错误原因有三层:

  1. 控制器可能在更新 StatefulSet 后崩溃,重启时 phase 仍然是 Ready
  2. StatefulSet 镜像更新完成,不代表数据库二进制升级或数据目录迁移完成;
  3. 另一个控制器实例或重复队列事件可能再次执行相同动作。

更可靠的做法是把动作外化为可观察资源,并以资源结果作为进度事实:

DatabaseCluster.status.upgrade.operationID = "upgrade-16-to-17-<generation>"
Job/Hook.status.succeeded = 1
数据库实际版本 = 17

operationID 用于幂等判断;数据库实际版本用于最终确认。


三、CRD 设计:Spec 表达意图,Status 记录观察

3.1 一个最小 CRD

以下 CRD 只展示结构重点:

apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: databaseclusters.database.example.com
spec:
  group: database.example.com
  scope: Namespaced
  names:
    plural: databaseclusters
    singular: databasecluster
    kind: DatabaseCluster
    shortNames:
    - dbc
  versions:
  - name: v1alpha1
    served: true
    storage: true
    subresources:
      status: {}
    schema:
      openAPIV3Schema:
        type: object
        required:
        - spec
        properties:
          spec:
            type: object
            required:
            - version
            - replicas
            properties:
              version:
                type: string
                minLength: 1
              replicas:
                type: integer
                minimum: 1
              storage:
                type: object
                properties:
                  size:
                    type: string
              backup:
                type: object
                properties:
                  schedule:
                    type: string
                  retention:
                    type: integer
                    minimum: 1
          status:
            type: object
            properties:
              observedGeneration:
                type: integer
                format: int64
              phase:
                type: string
              currentVersion:
                type: string
              conditions:
                type: array
                items:
                  type: object

status 子资源的意义是隔离权限和并发语义:用户通常修改 spec,控制器修改 status。如果不启用 status 子资源,普通更新可能覆盖控制器写入的状态,导致并发冲突和职责混乱。

生产 CRD 还应考虑:

  • x-kubernetes-validations 做跨字段校验;
  • 版本升级时使用 conversion webhook 或兼容字段;
  • 明确 storage: true 只能有一个版本;
  • 是否允许删除后保留 PVC、备份对象;
  • 字段默认值和不可变字段;
  • additionalProperties 的严格程度;
  • API 兼容和 webhook 可用性。

CRD 的 schema 校验只能验证结构和局部约束。例如它可以验证 replicas >= 1,却不能验证“从数据库 16 升级到 17 是否允许”。后者必须由控制器结合当前实际版本判断。

3.2 Conditions 比单一状态更适合自动化

Kubernetes 生态通常使用 Conditions 表达事实。一个资源可能同时满足:

Available=True
Progressing=False
Degraded=False
BackupReady=True

也可能处于:

Available=False
Progressing=True
Degraded=False
BackupReady=True

这表示服务暂时不可用,但正在进行可预期的升级,不等同于永久失败。

更新 Condition 时应保持以下语义:

  • type 是稳定名称,例如 Available
  • status 使用 TrueFalseUnknown
  • reason 是机器可识别的短名称;
  • message 提供人类诊断信息;
  • observedGeneration 说明结论对应哪个 Spec;
  • lastTransitionTime 只在状态真正变化时更新,避免每次 Reconcile 都产生无意义写入。

四、升级:镜像变更不等于数据库升级

4.1 Kubernetes 层升级与领域层升级不同

对 Deployment 或 StatefulSet 修改镜像,Kubernetes 可以完成 Pod 滚动替换;但数据库升级通常包含额外步骤:

  1. 检查数据库版本兼容性;
  2. 确认备份可用;
  3. 停止或隔离写入;
  4. 执行数据目录迁移、系统表升级或逻辑迁移;
  5. 启动新版本;
  6. 执行 schema 检查和读写探针;
  7. 重新建立复制关系;
  8. 确认应用连接和回滚边界。

因此不能简单地:

spec:
  template:
    spec:
      containers:
      - name: database
        image: postgres:17

然后将“StatefulSet 已完成更新”作为升级成功。对于某些数据库,旧版本数据目录不能被新版本直接启动;对于另一些数据库,二进制兼容不代表复制协议和系统表兼容。

4.2 升级路径必须显式建模

定义版本路径:

P(vc,vt)={direct,数据库支持直接升级[vc,v1,...,vt],必须经过中间版本invalid,不支持P(v_c, v_t) = \begin{cases} \text{direct}, & \text{数据库支持直接升级} \\ [v_c, v_1, ..., v_t], & \text{必须经过中间版本} \\ \text{invalid}, & \text{不支持} \end{cases}

其中 vcv_c 是当前实际版本,vtv_t 是目标版本。

例如:

16 -> 17       允许直接升级
15 -> 17       要求先 15 -> 16,再 16 -> 17
14 -> 17       不允许由该 Operator 自动执行

控制器不能只比较字符串:

if current != target {
    upgrade()
}

因为这会把降级、跨越不兼容大版本和格式不兼容都当成同一种操作。至少应返回明确 Condition:

- type: Upgradeable
  status: "False"
  reason: UnsupportedUpgradePath
  message: "direct upgrade from 15 to 17 is not supported; upgrade to 16 first"

4.3 升级 Job 的幂等设计

升级操作往往是有副作用的。控制器可以为每个 Spec generation 生成确定性的操作标识:

operationID = hash(namespace, name, observedGeneration, currentVersion, targetVersion)

然后创建一个带有该 ID 的 Job:

apiVersion: batch/v1
kind: Job
metadata:
  name: orders-upgrade-g4
  labels:
    database.example.com/cluster: orders
    database.example.com/operation-id: "g4-16-to-17"
spec:
  backoffLimit: 2
  ttlSecondsAfterFinished: 86400
  template:
    spec:
      restartPolicy: Never
      containers:
      - name: upgrade
        image: database-upgrade-tool:1.0
        args:
        - upgrade
        - --from=16
        - --to=17
        - --cluster=orders

这里有几个重要边界:

  • Job 的 Pod 模板通常不应在运行中随意修改;需要新操作时创建新 Job;
  • Job 成功不等于数据库一定已经达到目标版本,仍需重新探测;
  • Job 失败时要区分可重试的网络错误与不可重试的数据格式错误;
  • backoffLimit 只控制 Job Pod 的重试,不提供数据库事务回滚;
  • Job 可能被删除,状态因此丢失,所以 Operator 应把关键结果写入 CR Status 或持久化操作记录;
  • 升级工具必须自身幂等,重复执行不能再次破坏数据。

4.4 升级失败后的状态不能伪装成 Ready

升级失败可能有不同结果:

结果 数据库状态 Operator 应做什么
Job 启动失败 数据库仍运行旧版本 保持服务,标记升级失败,允许修复后重试
迁移前失败 数据库仍为旧版本 标记失败,不应修改 currentVersion
迁移中进程退出 状态未知 先执行探测和恢复流程,不能直接重试
数据目录已迁移、服务未启动 可能只能启动新版本 进入恢复路径,禁止自动降级
新版本启动但复制不一致 服务不满足拓扑不变量 标记 Degraded,保留现场供诊断

尤其不能在看到 Pod Ready=True 后直接把 currentVersion 写成目标版本。currentVersion 应来自数据库实际查询、可信管理接口或升级工具的可验证输出。


五、备份:复制数据不是备份,创建快照也不自动保证一致性

5.1 备份的定义和一致性层级

备份是能够在未来独立读取并用于恢复的一份数据副本。它至少涉及:

  • 数据内容;
  • 元数据和版本信息;
  • 访问凭据;
  • 加密密钥或密钥引用;
  • 备份位置;
  • 保留策略;
  • 校验和;
  • 恢复所需的日志或增量链。

数据库备份的一致性常见分为:

  1. 崩溃一致性:相当于机器突然断电后,数据库依靠 WAL 或日志恢复。
  2. 应用一致性:备份期间数据库知道备份边界,并保证事务和日志关系可恢复。
  3. 逻辑一致性:导出的表、schema、权限和数据在逻辑层可导入。
  4. 时间点可恢复(PITR):基线备份加连续日志,可以恢复到某个时间点。

PVC 快照是否能达到哪一层,取决于数据库、文件系统、CSI 驱动和快照协调机制。Kubernetes 只提供对象和接口,不保证任意数据库的应用一致性。

5.2 VolumeSnapshot 不是所有集群都有

CSI Volume Snapshot 使用 snapshot.storage.k8s.io API,但必须满足:

  • 集群安装了 snapshot CRD 和控制器;
  • 使用的 CSI 驱动支持快照;
  • 存储后端支持该能力;
  • StorageClass 和权限配置正确;
  • 数据库能接受该快照时刻的一致性语义。

检查环境:

kubectl api-resources | grep -i volumesnapshot
kubectl get volumesnapshotclass
kubectl get crd volumesnapshots.snapshot.storage.k8s.io

如果资源不存在,下面的对象不能直接使用:

apiVersion: snapshot.storage.k8s.io/v1
kind: VolumeSnapshot
metadata:
  name: orders-before-upgrade
  namespace: data
spec:
  volumeSnapshotClassName: csi-example
  source:
    persistentVolumeClaimName: orders-data-orders-0

预期验证结果包括:

kubectl get volumesnapshot orders-before-upgrade -n data \
  -o jsonpath='{.status.readyToUse}{"\n"}'

输出 true 只说明底层快照报告可使用,不等于数据库恢复后一定能启动,也不等于跨集群灾备成立。

5.3 备份流程中的状态边界

一个完整的备份状态不应只有“Job 存在”:

Requested
  -> Running
  -> Uploaded
  -> Verified
  -> Retained

其中 Verified 至少可以包含:

  • 对象存在;
  • 大小非零且符合预期;
  • 校验和匹配;
  • 备份清单可读取;
  • 数据库恢复工具能够解析;
  • 对于逻辑备份,测试导入或 schema 检查成功。

一个简单的备份 Job 示例:

apiVersion: batch/v1
kind: Job
metadata:
  name: orders-backup-20250110
  namespace: data
spec:
  backoffLimit: 2
  template:
    metadata:
      labels:
        database.example.com/cluster: orders
    spec:
      restartPolicy: Never
      containers:
      - name: backup
        image: database-backup-tool:1.0
        env:
        - name: BACKUP_DESTINATION
          value: "s3://example-bucket/orders/20250110"
        - name: DATABASE_DSN
          valueFrom:
            secretKeyRef:
              name: orders-credentials
              key: dsn
        command:
        - /bin/backup
        - --verify

这个示例还缺少生产必需项,例如对象存储凭据、TLS、加密、超时和网络策略。更重要的是,Job.status.succeeded=1 只说明容器以成功状态退出。Operator 仍应读取备份工具生成的 manifest,并检查它与当前数据库版本、LSN 或时间点的关系。

5.4 保留策略和删除策略是不同问题

retention: 7 可能表示:

  • 保留最近 7 个成功备份;
  • 保留最近 7 天;
  • 保留 7 个全量加增量链。

控制器必须明确语义,否则删除逻辑可能误删仍被增量链依赖的基础备份。

同样,删除 DatabaseCluster 时:

  • 删除 StatefulSet 不一定删除 PVC;
  • PVC 是否删除取决于 owner reference、StorageClass 回收策略和控制器行为;
  • 删除 Kubernetes 对象不会删除对象存储中的备份;
  • 云厂商快照可能有独立生命周期和费用。

因此 CRD 应提供显式策略,例如:

spec:
  deletionPolicy: Retain
  backup:
    deletionPolicy: Retain

Retain 不是绝对安全保证,而是控制器不主动删除;管理员仍可能通过其他系统删除存储。


六、恢复:先定义恢复目标,再设计控制器流程

6.1 RPO 和 RTO

恢复设计必须先定义两个量:

  • RPO(Recovery Point Objective):最多允许丢失多长时间的数据。
  • RTO(Recovery Time Objective):从故障开始到服务恢复允许经过多长时间。

例如:

RPO = 15 分钟
RTO = 60 分钟

如果只有每天一次全量备份,通常无法证明 RPO 为 15 分钟。要达到这个目标,可能需要 WAL、增量日志、跨区域复制或同步副本。Kubernetes Operator 只能编排这些组件,不能凭 CRD 字段自动创造数据库级能力。

6.2 恢复的状态机

恢复一般应是显式操作,而不是控制器看到 PVC 丢失就自动猜测。可以设计:

spec:
  restore:
    source:
      backupRef:
        name: orders-backup-20250110
    targetTime: "2025-01-10T01:45:00Z"
    confirm: true

控制器流程:

  1. 校验恢复源存在且校验通过;
  2. 获取数据库版本、备份格式和目标版本;
  3. 停止写入或切换到隔离状态;
  4. 创建临时恢复 Job 或新 PVC;
  5. 导入全量数据;
  6. 应用增量日志到目标时间点;
  7. 启动数据库;
  8. 执行系统表、schema、权限和业务探针;
  9. 验证复制关系;
  10. 切换 Service 或主节点;
  11. 写入恢复结果和恢复时间点。

恢复期间不能直接覆盖唯一生产 PVC 并期待失败后自动回滚。更安全的路径是:

备份源
  -> 新 PVC / 临时实例
  -> 恢复与校验
  -> 只读验证
  -> 切换流量
  -> 保留旧数据一段时间

这样可以将“恢复动作失败”和“原生产数据被破坏”隔离开。

6.3 恢复后的验证必须是领域验证

以下检查不能单独证明恢复成功:

kubectl get pod
# Pod 为 Running

至少应分层验证:

Kubernetes 层:
  Pod Ready、PVC Bound、Service Endpoints 正常

数据库层:
  可以建立连接
  数据库版本正确
  系统表和 schema 可读取
  主节点或领导者状态正确
  复制槽、WAL 或日志链正常

业务层:
  关键表存在
  关键查询返回预期结果
  应用账号权限正确
  读写或只读策略符合预期

恢复后的读写验证可能产生新数据,因此应使用专用测试事务、回滚事务或只读探针。不能在生产表中写入“恢复测试记录”后忘记清理,并把它误判为业务数据。


七、并发、故障和一致性边界

7.1 单个资源的并发更新

CR 同时可能被:

  • 用户更新 spec
  • Operator 更新 status
  • webhook 设置默认值;
  • 其他控制器添加 metadata。

Kubernetes 对象带有 metadata.resourceVersion。更新旧版本对象通常会得到冲突:

the object has been modified; please apply your changes to the latest version

控制器的处理方式是重新读取最新对象后重试,而不是无条件覆盖。状态更新通常应只修改 status,可使用 patch 减少与 Spec 的冲突。

7.2 多个操作必须互斥

备份、升级、恢复和扩缩容可能互相影响。可定义互斥规则:

AtMostOne(operation)=x{upgrade, restore, resize}active(x)1\text{AtMostOne}(operation) = \sum_{x \in \{\text{upgrade, restore, resize}\}} active(x) \leq 1

备份是否与升级互斥则取决于数据库能力。若升级必须先有稳定备份,升级前可以等待备份完成;升级进行中是否允许增量备份,需要由数据库工具明确支持。

实现上可以使用:

  • CR Status 中的 operation record;
  • 带唯一 label 的 Job;
  • Lease 或数据库级锁;
  • 通过 API Server 的对象版本冲突实现竞争控制。

只在进程内使用 sync.Mutex 不够,因为控制器可能多副本运行、进程可能重启,而且锁不会跨进程保存。

7.3 事件顺序不能被假设

Informer 事件可能重复、合并或延迟。控制器不能假设:

先收到 StatefulSet 更新,再收到 Pod 更新,再收到 Job 更新

实际可能先观察到 Pod,再观察到 StatefulSet,也可能中间丢失事件但随后通过重新 list 恢复。Reconcile 每次都应从当前缓存状态重新计算,而不是仅处理事件携带的“增量动作”。

缓存读取也可能短暂落后于 API Server。刚创建对象后马上从 cache 读取不到是正常现象,因此逻辑要能接受短暂的 NotFound,并通过后续事件或 requeue 收敛。

7.4 删除与 Finalizer

如果数据库有外部资源,例如对象存储备份、云盘、DNS、云数据库实例,删除 CR 前可能需要清理这些资源。控制器可以添加 finalizer:

database.example.com/finalizer

删除流程变为:

  1. API Server 设置 deletionTimestamp
  2. 控制器发现删除请求;
  3. 执行外部资源清理或根据策略保留;
  4. 清理完成后移除 finalizer;
  5. API Server 最终删除 CR。

风险在于 finalizer 可能永久阻塞删除:

  • 控制器没有权限;
  • 外部 API 永久不可达;
  • 清理逻辑没有超时;
  • 控制器升级后不再识别旧 finalizer;
  • 清理动作本身不是幂等的。

因此必须记录清理失败 Condition,设置超时和人工介入路径。直接用 kubectl patch 强行移除 finalizer 可能导致外部资源泄漏,不应作为常规修复手段。


八、从零验证一个 Operator 环境

8.1 创建和检查 CRD

安装 CRD 后:

kubectl apply -f databasecluster-crd.yaml
kubectl wait \
  --for=condition=Established \
  crd/databaseclusters.database.example.com \
  --timeout=60s

预期结果:

customresourcedefinition.apiextensions.k8s.io/databaseclusters.database.example.com configured

检查 API 是否可发现:

kubectl api-resources | grep databaseclusters
kubectl explain databasecluster.spec

如果 kubectl explain 没有字段,可能是 CRD schema 未正确发布,或者本地发现缓存尚未更新。不要在 API 发现失败时继续测试控制器行为。

8.2 应用实例并观察收敛

kubectl create namespace data

kubectl apply -f orders.yaml

kubectl get dbc orders -n data -o yaml
kubectl describe dbc orders -n data
kubectl get events -n data --sort-by=.lastTimestamp

应重点观察:

metadata.generation
status.observedGeneration
status.conditions
status.currentVersion
status.readyReplicas

一个健康的最终关系类似:

generation:         1
observedGeneration: 1
phase:              Ready
currentVersion:     16.2
targetVersion:      16.2
Available=True

如果:

generation:         2
observedGeneration: 1

说明用户修改已进入 API Server,但控制器尚未处理完成。此时不能把旧的 Available=True 理解为新 Spec 已生效;Condition 必须结合 observedGeneration 阅读。

8.3 按故障路径诊断

如果 CR 长时间停留在 Provisioning

  1. 看 Condition 的 reasonmessage
  2. 查看控制器日志;
  3. 检查 StatefulSet、PVC、Service;
  4. 检查 Pod 事件;
  5. 检查 RBAC 和 webhook;
  6. 确认存储类、节点拓扑和镜像可拉取。

例如:

kubectl get statefulset,pod,pvc,job -n data \
  -l database.example.com/cluster=orders

kubectl describe pod orders-0 -n data
kubectl logs deploy/database-operator -n operators --all-containers

常见失败表现与原因:

表现 可能原因
PVC 一直 Pending StorageClass 不存在、拓扑不匹配、容量不足
Pod Pending 节点资源不足、亲和性规则冲突、污点未容忍
Pod CrashLoopBackOff 数据目录版本不兼容、配置错误、权限错误
Job BackoffLimitExceeded 备份凭据、网络、工具参数或数据库锁问题
CR Available=True 但应用连接失败 Operator 的健康条件过弱,只检查了 Pod
删除 CR 卡住 finalizer 清理失败或外部 API 不可用
升级反复创建 Job operationID 不稳定,或未持久化操作完成事实

九、Operator 的升级策略

Operator 自身升级与它管理的数据库升级是两个不同过程。

9.1 Operator 镜像升级

Operator Deployment 的滚动升级可能导致:

  • 新旧控制器同时短暂存在;
  • leader election 重新选主;
  • 新版本重新解释旧 Status;
  • webhook 版本不匹配;
  • 新代码改变默认值或资源命名规则。

因此控制器需要:

  • 对旧 CR Status 向后兼容;
  • 使用稳定的 owner reference、label 和 operation ID;
  • 让 Reconcile 可中断、可恢复;
  • 不依赖内存中的任务队列;
  • 在 CRD schema、webhook 和 controller 之间按兼容顺序发布;
  • 对存量资源进行迁移,而不是只处理新创建对象。

9.2 数据库版本升级

数据库升级则应由数据库 Operator 的领域协议控制。常见策略包括:

  • 原地升级:在现有 PVC 上执行迁移。资源成本低,但回滚困难。
  • 蓝绿恢复:从备份创建新集群,验证后切流。隔离性好,但需要额外存储和切换机制。
  • 逻辑复制迁移:新旧版本并行,追平后切换。复杂度较高,但停机时间可能更短。
  • 逐副本滚动升级:只适用于数据库明确支持该拓扑和协议,不是 StatefulSet 自带保证。

Kubernetes StatefulSet 的有序创建、终止和稳定网络标识只是编排语义,不会自动提供数据库共识、主从一致性或安全降级。


十、测试:分别验证控制逻辑、API 行为和真实系统

Operator 测试不能只验证“调用了 Create”。真正需要验证的是:在重复事件、部分失败、控制器重启和外部状态变化后,系统是否仍能达到正确状态。

10.1 纯单元测试:先验证状态转移

把领域决策从 Kubernetes 客户端中分离:

type Facts struct {
    CurrentVersion   string
    TargetVersion    string
    ReadyReplicas    int32
    DesiredReplicas  int32
    BackupVerified   bool
    UpgradeRunning   bool
    RestoreRunning   bool
}

type Decision struct {
    Phase  string
    Action string
    Reason string
}

func Decide(f Facts) Decision {
    if f.RestoreRunning {
        return Decision{Phase: "Restoring", Action: "wait", Reason: "RestoreRunning"}
    }
    if f.UpgradeRunning {
        return Decision{Phase: "Upgrading", Action: "wait", Reason: "UpgradeRunning"}
    }
    if f.CurrentVersion != "" && f.CurrentVersion != f.TargetVersion {
        if !f.BackupVerified {
            return Decision{Phase: "Upgrading", Action: "request-backup", Reason: "BackupRequired"}
        }
        return Decision{Phase: "Upgrading", Action: "start-upgrade", Reason: "VersionDrift"}
    }
    if f.ReadyReplicas != f.DesiredReplicas {
        return Decision{Phase: "Provisioning", Action: "ensure-topology", Reason: "ReplicasNotReady"}
    }
    return Decision{Phase: "Ready", Action: "none", Reason: "AllInvariantsSatisfied"}
}

测试应覆盖:

当前版本为空、目标版本为 16
副本未就绪
版本不一致但没有已验证备份
升级 Job 已运行
恢复与升级同时请求
Ready 状态下重复 Reconcile

特别重要的是反例测试:

Job 已经成功,但 CR Status 尚未更新
控制器重启后再次看到同一个 Job
API Update 返回 Conflict
StatefulSet 存在但 Pod 属于旧版本

单元测试应证明决策是确定的:

Decide(F)=Decide(F)\operatorname{Decide}(F) = \operatorname{Decide}(F)

并尽量保证同一事实集合不会产生互相冲突的动作。

10.2 fake client 测试:快速验证对象读写

controller-runtime 的 fake client 适合测试:

  • 是否创建了预期的 Service、StatefulSet、Job;
  • owner reference 和 label 是否正确;
  • 已存在资源时是否避免重复创建;
  • Status 是否写入正确 Condition;
  • 删除流程是否添加或移除 finalizer。

但 fake client 不是完整 API Server。它可能不会完整模拟:

  • OpenAPI schema 校验;
  • admission webhook;
  • defaulting;
  • resourceVersion 冲突;
  • informer cache 延迟;
  • Job、StatefulSet 控制器的实际行为。

因此 fake 测试通过,不代表真实集群一定正确。

10.3 envtest:验证 Kubernetes API 交互

envtest 通常启动本地 kube-apiserveretcd,适合验证 CRD、API 读写、Status 子资源和 webhook。典型流程:

testEnv := &envtest.Environment{
    CRDDirectoryPaths: []string{"config/crd/bases"},
}

cfg, err := testEnv.Start()
if err != nil {
    t.Fatal(err)
}
defer testEnv.Stop()

k8sClient, err := client.New(cfg, client.Options{Scheme: scheme})
if err != nil {
    t.Fatal(err)
}

使用前需要准备匹配版本的测试二进制,并保证 CRD 已生成。envtest 默认不会替你运行 Deployment、StatefulSet、Job、Service 等控制器,因此:

创建 StatefulSet != Pod 自动出现
创建 Job != Job 自动执行
创建 Service != 真实网络流量可达

如果测试目标是 Operator 观察子资源最终状态,必须:

  • 手动创建模拟的 Pod/Job 状态;
  • 或使用真实 kind 集群做集成测试。

10.4 集成测试:验证控制器与真实 API 资源

kind、minikube 或专用测试集群可以验证:

  • Controller Deployment 能否启动;
  • RBAC 是否完整;
  • watch 和 owner reference 是否有效;
  • StatefulSet、Job 等内置控制器的联动;
  • PVC 调度和节点拓扑;
  • webhook TLS 和证书;
  • 备份工具容器能否访问数据库和对象存储。

示例测试步骤:

kubectl apply -f config/crd/bases/
kubectl apply -f config/rbac/
kubectl apply -f config/manager/

kubectl wait --for=condition=Available \
  deployment/database-operator \
  -n database-operator-system \
  --timeout=120s

kubectl apply -f config/samples/database_v1alpha1_databasecluster.yaml

kubectl wait --for=jsonpath='{.status.conditions[?(@.type=="Available")].status}'=True \
  dbc/orders -n data \
  --timeout=10m

kubectl wait 的 JSONPath 语法和 shell 引号容易受环境影响;更复杂的条件应由 Go 测试客户端轮询,并设置明确超时。测试不能无限等待,否则一个权限错误会表现为 CI 卡死。

10.5 端到端测试:必须包含破坏性路径

数据库 Operator 的端到端测试至少应包含:

  1. 创建集群;
  2. 等待真实数据库连接成功;
  3. 写入一组可识别数据;
  4. 触发备份并验证备份产物;
  5. 修改版本,验证升级路径;
  6. 在升级中删除或重启控制器;
  7. 验证控制器恢复后继续执行;
  8. 删除一个 Pod,验证拓扑恢复;
  9. 从备份创建恢复实例;
  10. 验证数据、版本和业务探针;
  11. 测试错误凭据、不可用对象存储和 PVC 调度失败;
  12. 验证恢复或人工修复路径。

测试“成功路径”只能证明演示环境可用,不能证明控制器在故障下不会重复执行破坏性操作。


十一、规范保证、常见实现和生产边界

需要明确区分三个层次。

Kubernetes 规范保证

Kubernetes API 提供:

  • 声明式对象存储;
  • resourceVersion 并发控制;
  • informer/watch 机制;
  • CRD 和 API 版本;
  • Job、StatefulSet、PVC 等资源的通用编排语义;
  • finalizer 和 owner reference 的对象生命周期机制。

这些机制不保证:

  • 数据库事务一致性;
  • 备份可恢复;
  • 数据库主从切换正确;
  • 云存储跨区域持久;
  • 任意版本之间可升级;
  • Pod Ready 等于业务可用。

Operator 的常见实现

Operator 通常实现:

  • 领域状态机;
  • 资源创建和拓扑维护;
  • 健康探针;
  • 升级 Job;
  • 备份和恢复工作流;
  • Conditions 和事件;
  • finalizer;
  • 外部系统客户端;
  • 备份保留和操作互斥。

这些都不是 Kubernetes 自动完成的功能,质量取决于 Operator 的代码、数据库工具和运行环境。

生产风险

生产环境还会受到以下因素影响:

  • 云厂商 CSI 快照实现和恢复速度;
  • 对象存储的权限、生命周期和跨区域复制;
  • 节点故障域和存储拓扑;
  • 镜像供应链和备份工具版本;
  • KMS 密钥是否可恢复;
  • 网络策略、DNS、代理和防火墙;
  • 数据库许可、复制协议和升级限制;
  • Operator 自身升级时的兼容性。

因此,“资源在 Kubernetes 中”不等于“数据责任由 Kubernetes 承担”。数据库数据的持久性、备份可用性和恢复目标,必须由 Operator、数据库工具、存储系统和运维流程共同验证。


十二、一个可执行的最小验收标准

在把 Operator 视为可用之前,应至少证明以下事实:

1. 新建 CR 后,控制器可以从空环境创建所需子资源。
2. Reconcile 重复执行不会创建重复 Job、Service 或 PVC。
3. 控制器重启后可以从 API 对象和外部事实恢复进度。
4. generation 与 observedGeneration 能区分旧状态和新 Spec。
5. Pod Running 不会被错误当作数据库 Ready。
6. 不支持的升级路径会被拒绝,而不是盲目修改镜像。
7. 升级前备份失败时不会继续执行升级。
8. 备份成功判定包含产物验证,而不是只看 Job 创建。
9. 恢复使用隔离目标,并在切流前执行数据库和业务验证。
10. 升级、恢复、扩缩容等冲突操作不会并行破坏数据。
11. finalizer 清理失败时能够诊断,不会静默卡死。
12. fake、envtest、集成和端到端测试分别覆盖其适用边界。

Operator 的核心不是资源模板,而是一个能够在不完整信息、重复事件、部分失败和进程重启下继续运行的领域状态机。升级要求控制器知道“当前版本”和“目标版本”的差异,备份要求它知道“产物已验证”与“任务已创建”的差异,恢复要求它知道“Pod 启动”与“数据和业务已恢复”的差异,测试则要求这些差异都能被故障注入和自动断言。

当这些状态、转移条件、幂等边界和责任边界被明确建模后,Kubernetes 才真正成为数据库等有状态系统的控制平面,而不只是运行几个容器的调度平台。


系列导航与关联阅读

官方资料

本文依据 Kubernetes、CNCF 与相关项目官方文档重新梳理;正文和生产清单由 WR BLOG 编写。