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/v1、batch/v1、apps/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 和备份。
控制器通过以下过程工作:
其中:
- 是期望状态,通常来自 CR 的
spec; - 是观察到的状态,包括缓存中的 Kubernetes 对象和外部数据库状态;
- 是环境事实,例如 API 错误、网络故障、权限不足;
- 是本轮要执行的动作集合,例如创建、更新、删除资源,或者重新排队。
控制器不是一次性脚本,而是反复计算:
当满足:
控制器才进入稳定状态。这里的 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
}
这段代码只展示控制流,真实实现还需要:
- 对状态更新使用独立的
Status().Update或 patch; - 处理并发更新导致的
Conflict; - 为关联资源设置 owner reference 或使用 label 关联;
- 对
Get、Create、Update、Patch的权限进行 RBAC 配置; - 不把临时错误永久写成失败终态;
- 区分“需要重试的错误”和“用户必须修改 Spec 的错误”。
RequeueAfter 不是替代事件监听的机制。关联资源变化应通过 watch 触发;定时 requeue 主要用于外部系统没有 Kubernetes 事件,或者需要定期重新探测数据库健康状态的场景。
二、领域状态机:为什么不能只用 phase
2.1 状态机的形式化定义
领域状态机可以表示为:
其中:
- :有限状态集合;
- :事件或观察结果集合;
- :状态转移函数;
- :初始状态;
- :满足某种业务目标的状态集合。
对 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版本;currentVersion与targetVersion分离,避免把“目标已写入”误报成“实际已完成”;readyReplicas是观察值,不是期望值;- 备份信息必须包含可验证的成功事实,而不是“Job 已创建”。
2.2 迁移条件必须由不变量决定
例如,进入 Ready 至少需要满足:
因此:
“Pod 为 Running”最多只能证明容器进程存活,不能推出数据库可写,更不能推出集群可恢复。
升级则要求更强的不变量:
其中:
ValidVersionPath:数据库支持从当前版本到目标版本的升级路径;BackupVerified:备份已完成且可被控制器识别;TopologyHealthy:当前拓扑满足升级前置条件;NoConflictingOperation:没有进行中的恢复、扩缩容或另一个升级。
如果控制器在 Ready 状态看到 spec.version 变化,应当先进入 Upgrading,而不是直接修改 StatefulSet 镜像并宣称升级成功。
2.3 一个完整的状态转换示例
假设初始对象为:
spec.version = 16
status.currentVersion = ""
status.phase = Pending
控制器按以下步骤推进:
- 校验
replicas >= 1、存储大小合法、版本格式可识别。 - 创建或修正 Secret、Service、PVC、StatefulSet。
- 观察 StatefulSet 副本是否就绪。
- 通过数据库探针确认 SQL 服务可连接。
- 读取数据库实际版本。
- 写入:
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
于是:
- 设置
Progressing=True; - 检查是否有适用于 16→17 的升级路径;
- 请求或验证升级前备份;
- 将版本变更动作写入可观察资源,例如 Job;
- 等待 Job 成功;
- 重新探测数据库实际版本;
- 只有实际版本变为 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)
}
错误原因有三层:
- 控制器可能在更新 StatefulSet 后崩溃,重启时
phase仍然是Ready; - StatefulSet 镜像更新完成,不代表数据库二进制升级或数据目录迁移完成;
- 另一个控制器实例或重复队列事件可能再次执行相同动作。
更可靠的做法是把动作外化为可观察资源,并以资源结果作为进度事实:
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使用True、False、Unknown;reason是机器可识别的短名称;message提供人类诊断信息;observedGeneration说明结论对应哪个 Spec;lastTransitionTime只在状态真正变化时更新,避免每次 Reconcile 都产生无意义写入。
四、升级:镜像变更不等于数据库升级
4.1 Kubernetes 层升级与领域层升级不同
对 Deployment 或 StatefulSet 修改镜像,Kubernetes 可以完成 Pod 滚动替换;但数据库升级通常包含额外步骤:
- 检查数据库版本兼容性;
- 确认备份可用;
- 停止或隔离写入;
- 执行数据目录迁移、系统表升级或逻辑迁移;
- 启动新版本;
- 执行 schema 检查和读写探针;
- 重新建立复制关系;
- 确认应用连接和回滚边界。
因此不能简单地:
spec:
template:
spec:
containers:
- name: database
image: postgres:17
然后将“StatefulSet 已完成更新”作为升级成功。对于某些数据库,旧版本数据目录不能被新版本直接启动;对于另一些数据库,二进制兼容不代表复制协议和系统表兼容。
4.2 升级路径必须显式建模
定义版本路径:
其中 是当前实际版本, 是目标版本。
例如:
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 备份的定义和一致性层级
备份是能够在未来独立读取并用于恢复的一份数据副本。它至少涉及:
- 数据内容;
- 元数据和版本信息;
- 访问凭据;
- 加密密钥或密钥引用;
- 备份位置;
- 保留策略;
- 校验和;
- 恢复所需的日志或增量链。
数据库备份的一致性常见分为:
- 崩溃一致性:相当于机器突然断电后,数据库依靠 WAL 或日志恢复。
- 应用一致性:备份期间数据库知道备份边界,并保证事务和日志关系可恢复。
- 逻辑一致性:导出的表、schema、权限和数据在逻辑层可导入。
- 时间点可恢复(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
控制器流程:
- 校验恢复源存在且校验通过;
- 获取数据库版本、备份格式和目标版本;
- 停止写入或切换到隔离状态;
- 创建临时恢复 Job 或新 PVC;
- 导入全量数据;
- 应用增量日志到目标时间点;
- 启动数据库;
- 执行系统表、schema、权限和业务探针;
- 验证复制关系;
- 切换 Service 或主节点;
- 写入恢复结果和恢复时间点。
恢复期间不能直接覆盖唯一生产 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 多个操作必须互斥
备份、升级、恢复和扩缩容可能互相影响。可定义互斥规则:
备份是否与升级互斥则取决于数据库能力。若升级必须先有稳定备份,升级前可以等待备份完成;升级进行中是否允许增量备份,需要由数据库工具明确支持。
实现上可以使用:
- 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
删除流程变为:
- API Server 设置
deletionTimestamp; - 控制器发现删除请求;
- 执行外部资源清理或根据策略保留;
- 清理完成后移除 finalizer;
- 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:
- 看 Condition 的
reason和message; - 查看控制器日志;
- 检查 StatefulSet、PVC、Service;
- 检查 Pod 事件;
- 检查 RBAC 和 webhook;
- 确认存储类、节点拓扑和镜像可拉取。
例如:
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 属于旧版本
单元测试应证明决策是确定的:
并尽量保证同一事实集合不会产生互相冲突的动作。
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-apiserver 和 etcd,适合验证 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 的端到端测试至少应包含:
- 创建集群;
- 等待真实数据库连接成功;
- 写入一组可识别数据;
- 触发备份并验证备份产物;
- 修改版本,验证升级路径;
- 在升级中删除或重启控制器;
- 验证控制器恢复后继续执行;
- 删除一个 Pod,验证拓扑恢复;
- 从备份创建恢复实例;
- 验证数据、版本和业务探针;
- 测试错误凭据、不可用对象存储和 PVC 调度失败;
- 验证恢复或人工修复路径。
测试“成功路径”只能证明演示环境可用,不能证明控制器在故障下不会重复执行破坏性操作。
十一、规范保证、常见实现和生产边界
需要明确区分三个层次。
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 完整学习路线:从 Pod 与控制面到安全、运维和 Operator
- 上一篇:Kubernetes Controller 开发:client-go、Cache、Queue、Reconcile 和幂等
- 下一篇:Kubernetes Admission Webhook 开发:Mutating、Validating、证书和高可用
- 延伸:数据库运行在 Kubernetes:Operator、存储、拓扑、备份和责任边界
官方资料
本文依据 Kubernetes、CNCF 与相关项目官方文档重新梳理;正文和生产清单由 WR BLOG 编写。

评论
0 条讨论