Kubernetes RPO、RTO 与恢复演练
一家团队把备份计划设为每小时一次,于是对外承诺 RPO 一小时、RTO 两小时。真正演练时,最近三次 Backup 中一次部分成功、一次对象仓库复制滞后、一次数据库快照缺少 WAL;能通过业务查询的恢复点已经是七小时前。恢复任务在四十分钟后显示 Completed,但目标集群还没有 KMS 权限,数据库回放、缓存预热、身份校验和切流又用了四小时。
调度周期不是 RPO,Restore 任务时长也不是 RTO。RPO 要从故障切点回看最后一个被证明可恢复的业务数据水位;RTO 要从组织开始恢复计时,一直到业务在批准入口满足服务目标。只有演练把这两个数字重新算出来,灾备承诺才不是配置文件里的愿望。
两个目标必须落到业务时钟
NIST 对 RPO 的定义是中断后数据必须恢复到的时间点;NIST 对 RTO 的定义是中断后系统资源必须恢复的总时长。工程上可写成:
actual_rpo = incident_cut_time - last_validated_business_watermark_time
actual_rto = business_service_accepted_time - recovery_start_timelast_validated_business_watermark_time 不是 Backup 的创建时间。它来自恢复后的数据库提交序列、消息 offset、对象版本或业务不可变量,并且要能追溯到某个恢复点。business_service_accepted_time 也不是 Pod Ready;它要等身份、数据、依赖、业务查询、容量和入口全部通过。
目标要按业务分层。例如订单写链比历史报表需要更小的数据损失窗口,支付回调比内部管理页需要更短的服务恢复时间。一个 Namespace 里可以同时存在多个保护等级,工具调度、保留与演练频率应从等级映射,而不是所有 PVC 共用一个 cron。
建立一条能重算的演练时间轴
每次演练分配唯一 ID,并记录 UTC 时间。至少保留以下时点:
| 事件 | 时点 | 证据 |
|---|---|---|
| 最后业务提交 | T_commit | 数据库 LSN/GTID、业务序列、对象版本或消息 offset |
| 备份采集完成 | T_backup | Backup 与子任务状态、对象/卷清单 |
| 异地副本可读 | T_replica | 目标账号列举、读取和解密结果 |
| 故障切点 | T_incident | 场景注入记录或事件时间 |
| 恢复开始 | T_start | 审批与首个恢复动作 |
| 数据恢复完成 | T_data | checksum、回放终点与查询结果 |
| 应用可校验 | T_app | 版本、身份、读写与副作用检查 |
| 业务接管 | T_accept | SLI 达标、流量批准与观察窗口 |
由此得到:
actual_rpo = T_incident - T_commit
replica_visibility_age_at_incident = T_incident - T_replica
data_phase = T_data - T_start
application_phase = T_app - T_data
traffic_phase = T_accept - T_app
actual_rto = T_accept - T_startreplica_visibility_age_at_incident 只能说明目标身份最后一次成功读到异地副本距故障已有多久,不能冒充真正的复制延迟。复制延迟必须比较同一份数据在源端提交、目标端可读的两个水位;如果只有仓库探测时间而没有业务水位,就只能标记为“复制状态未知”,不能据此兑现 RPO。
所有时间从同一受控时钟采集。节点时钟漂移、操作者本地时区和手工填写会破坏计算;演练控制器、日志平台和业务 fixture 应使用 UTC,并保存原始时间戳而不是只保存图表截图。
用无敏感数据 fixture 制造业务水位
在隔离 namespace 部署一个最小水位生成器。下面用 ConfigMap 展示方法,生产数据库应改成事务内递增序列、提交时间和校验和:
export DRILL_ID="drill-$(date -u +%Y%m%dT%H%M%SZ)"
export DRILL_NS='recovery-drill'
export DRILL_CTX='<isolated-source-context>'
kubectl --context "$DRILL_CTX" create namespace "$DRILL_NS"
kubectl --context "$DRILL_CTX" -n "$DRILL_NS" create configmap business-watermark \
--from-literal=drillId="$DRILL_ID" \
--from-literal=sequence=1000 \
--from-literal=committedAt="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
kubectl --context "$DRILL_CTX" -n "$DRILL_NS" get configmap business-watermark -o yamlConfigMap 只能验证对象恢复链,不代表数据库一致性。数据库 fixture 至少应包含:单调递增提交序列、跨表一致性约束、一条可回放日志位置、已提交与未提交事务、错误身份拒绝,以及不触发生产副作用的测试租户。业务 owner 提供查询和期望结果,平台 owner 不能自行用 Pod Ready 代替。
每次备份前后都读取水位并关联恢复点:
export BACKUP_NAME="${DRILL_ID}-rp1"
export T_COMMIT="$(kubectl --context "$DRILL_CTX" -n "$DRILL_NS" \
get configmap business-watermark -o jsonpath='{.data.committedAt}')"
velero --kubecontext "$DRILL_CTX" backup create "$BACKUP_NAME" \
--include-namespaces "$DRILL_NS" --wait
velero --kubecontext "$DRILL_CTX" backup describe "$BACKUP_NAME" --details
printf 'drill_id=%s\nbackup=%s\ncommit=%s\n' \
"$DRILL_ID" "$BACKUP_NAME" "$T_COMMIT"若使用 PVC,继续记录快照 handle、PodVolumeBackup、DataUpload、仓库对象或数据库原生备份 ID。顶层 Completed 但子任务失败、快照不可读或应用 Hook 未解冻,都不能把该点列为可用水位。
演练不是生产环境里的破坏测试
标准演练环境使用独立账号或项目、隔离网络、非生产 DNS、短期恢复身份和脱敏 fixture。恢复仓库以只读方式接入,删除与保留策略由另一身份控制。目标集群禁止访问生产写端点,邮件、短信、支付、消息 consumer 和定时任务默认关闭。
演练包至少含:场景、保护等级、目标 RPO/RTO、固定恢复点、目标集群 revision、恢复顺序、业务断言、故障注入、回退、清理和 owner。真实 bucket、账号、token、kubeconfig、KMS key ID、Secret data 与客户记录不进入演练报告。
开始前确认恢复点可从目标身份读取和解密:
export TARGET_CTX='<isolated-recovery-context>'
kubectl --context "$TARGET_CTX" auth can-i get backups.velero.io -n velero
kubectl --context "$TARGET_CTX" auth can-i delete backups.velero.io -n velero
kubectl --context "$TARGET_CTX" -n velero get backupstoragelocations -o wide
velero --kubecontext "$TARGET_CTX" backup get读取应为 yes,删除对演练身份应为 no。BSL 显示 Available 只代表插件探测成功,仍需实际恢复并验证数据。
正向演练:从固定恢复点走到业务接管
先记录恢复开始时间,不要从点击 Restore 后才挑恢复点:
export RESTORE_NS="${DRILL_NS}-restored"
export RESTORE_NAME="${DRILL_ID}-restore"
export T_START="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
velero --kubecontext "$TARGET_CTX" restore create "$RESTORE_NAME" \
--from-backup "$BACKUP_NAME" \
--namespace-mappings "$DRILL_NS:$RESTORE_NS" \
--existing-resource-policy=none --wait
velero --kubecontext "$TARGET_CTX" restore describe "$RESTORE_NAME" --details
kubectl --context "$TARGET_CTX" -n "$RESTORE_NS" get events --sort-by=.lastTimestamp按恢复依赖图验证 CRD/Operator、身份、PVC、数据、应用和入口。fixture 的业务水位必须与记录一致:
export RESTORED_SEQUENCE="$(kubectl --context "$TARGET_CTX" -n "$RESTORE_NS" \
get configmap business-watermark -o jsonpath='{.data.sequence}')"
export RESTORED_COMMIT="$(kubectl --context "$TARGET_CTX" -n "$RESTORE_NS" \
get configmap business-watermark -o jsonpath='{.data.committedAt}')"
test "$RESTORED_SEQUENCE" = '1000'
test "$RESTORED_COMMIT" = "$T_COMMIT"
export T_DATA="$(date -u +%Y-%m-%dT%H:%M:%SZ)"数据库场景继续检查 schema、提交序列、checksum、日志回放终点、隔离写入可读回和未提交事务未出现。应用场景检查镜像 digest、配置版本、错误身份拒绝、队列不连接生产以及关键 API 的延迟和错误率。
完成内部 SLI 后记录 T_app;使用测试 Host 或内部地址通过观察窗口,再由业务 owner 记录 T_accept。可以用跨平台脚本或时间序列系统计算差值,下面给出 Node.js 示例,避免依赖某个 shell 的日期算法:
export T_INCIDENT='<scenario-cut-utc>'
export T_APP="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
export T_ACCEPT="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
node -e "
const t = n => {
const value = Date.parse(process.env[n]);
if (!Number.isFinite(value)) throw new Error('invalid UTC timestamp: ' + n);
return value;
};
const minutes = ms => Math.round(ms / 6000) / 10;
const result = {
actualRpoMinutes: minutes(t('T_INCIDENT') - t('RESTORED_COMMIT')),
dataPhaseMinutes: minutes(t('T_DATA') - t('T_START')),
appPhaseMinutes: minutes(t('T_APP') - t('T_DATA')),
trafficPhaseMinutes: minutes(t('T_ACCEPT') - t('T_APP')),
actualRtoMinutes: minutes(t('T_ACCEPT') - t('T_START'))
};
if (Object.values(result).some(value => value < 0)) {
throw new Error('timeline is inconsistent: ' + JSON.stringify(result));
}
console.log(JSON.stringify(result, null, 2));
"预期所有值为非负,且能由保存的 UTC 时间重新计算;无效时间或阶段倒序会让脚本直接失败,而不是生成看似正常的负数。若 actualRpoMinutes 满足目标但 actualRtoMinutes 超标,应定位最长阶段;不能通过修改演练起始时间让报表变绿。
反向演练一:让最新恢复点不可用
选择一个故意缺少仓库权限、KMS grant 或目标复制的恢复点,保持目标入口关闭并尝试读取:
export BAD_BACKUP='<known-unavailable-recovery-point>'
velero --kubecontext "$TARGET_CTX" restore create "${DRILL_ID}-bad-rp" \
--from-backup "$BAD_BACKUP" --wait || true
velero --kubecontext "$TARGET_CTX" restore describe "${DRILL_ID}-bad-rp" --details
kubectl --context "$TARGET_CTX" -n velero logs deploy/velero --since=15m预期出现对象不存在、签名/权限拒绝、KMS 解密失败、快照 handle 不可达或异步 item operation 失败。系统应回退到前一个已验证恢复点,并据此重新计算实际 RPO。这个反例证明“每小时创建一个 Backup”不会自动形成一小时 RPO。
不要为让演练继续而给目标身份永久管理员权限。修复动作应是补齐批准的跨域副本、短期读取或 KMS grant,并保留失败恢复点和回退决策的审计记录。
反向演练二:制造容量不足和长尾
在隔离目标降低 namespace ResourceQuota,或限制 data mover/恢复节点可用资源,再恢复多卷 fixture:
apiVersion: v1
kind: ResourceQuota
metadata:
name: recovery-pressure
namespace: recovery-drill-restored
spec:
hard:
requests.cpu: "500m"
requests.memory: 512Mi
persistentvolumeclaims: "1"kubectl --context "$TARGET_CTX" apply -f recovery-pressure.yaml
kubectl --context "$TARGET_CTX" -n "$RESTORE_NS" get events --sort-by=.lastTimestamp
kubectl --context "$TARGET_CTX" -n velero get podvolumerestores,datadownloads 2>/dev/null || true
kubectl --context "$TARGET_CTX" get pvc -A预期第二个 PVC 被配额拒绝,Pod Pending 或 data mover 排队,RTO 的数据阶段持续增长,但流量门禁不开放。记录队列最老年龄、有效吞吐、API 限流、KMS/对象请求错误、目标卷写入和 CPU/内存。移除实验配额后观察任务是否自动重试、需要新建 Restore 还是人工补偿,并把实际恢复语义写回运行手册。
这个反例用于校准恢复容量,不用于得出“把并发调到最大”的结论。并发超过对象仓库、网络、KMS、CSI 或目标卷能力后,重试和尾延迟可能让 RTO 更差。
按阶段拆解瓶颈,而不是笼统说恢复慢
| 阶段 | 典型瓶颈 | 必留证据 | 常见误判 |
|---|---|---|---|
| 决策与授权 | 通知、审批、身份签发 | 告警到确认、授权开始/结束 | 不计入 RTO |
| 基础设施 | 账号、配额、网络、节点池 | IaC duration、节点 Ready | 只测已有热备集群 |
| 平台能力 | CNI/CSI、CRD、Operator | rollout、API discovery、reconcile | Pod Running 等于插件可用 |
| 对象恢复 | API QPS、admission、冲突 | Restore item、Event、skipped | 顶层 Completed 等于完整 |
| 数据恢复 | 仓库、KMS、网络、卷 IOPS | bytes、吞吐、队列、checksum | PVC Bound 等于数据正确 |
| 应用恢复 | schema、缓存、队列积压 | 查询、SLI、业务不变量 | readiness 等于可接管 |
| 流量切换 | DNS、全局流量、连接排空 | 权重、TTL、错误率、单写证明 | CNAME 修改即结束 |
优化先针对最长阶段。冷备的基础设施时间过长,可以预置账号、网络和最小控制面;数据阶段过长,可以提高独立副本频率、按优先级分卷、预热恢复节点或改用日志回放;应用阶段长,应减少人工查询、固化业务断言并治理 controller 长尾。不能把入口提前开放当成 RTO 优化。
演练机制与频率由变化速度和风险决定
桌面推演验证人员、联系方式和决策;组件恢复验证单个 PVC、数据库或 Namespace;应用演练验证完整保护集合;集群演练验证空集群重建;区域演练再加入异地账号、仓库、KMS、DNS 和流量仲裁。不同层级互相补足,桌面推演不能代替真实数据恢复。
以下变化应触发额外演练:Kubernetes minor 升级、CSI/备份工具升级、StorageClass 或 KMS 迁移、CRD storage version 变化、数据库大版本升级、身份提供方调整、仓库保留策略变化、跨区域复制修改、业务数据量或峰值显著增长。固定季度演练无法覆盖两次演练之间的高风险变更。
每次演练至少加入一个失败注入:不可用快照、过期身份、KMS 拒绝、缺失 CRD、StorageClass 不兼容、目标配额不足、admission webhook 拒绝、对象仓库只读或入口无法回切。连续只跑成功路径会把运行手册训练成演示脚本。
权限、证据与敏感数据治理
演练负责人可以发起流程,但不应同时拥有仓库删除、KMS policy、生产 DNS 和业务验收全部权限。安全 owner 提供短期恢复身份;平台 owner 操作集群和工具;应用 owner 执行业务断言;业务 owner 接受 RPO/RTO 和降级;观察员记录时间与偏差。职责分离也用于验证关键人员缺席时能否接管。
证据包保存恢复点 ID、对象/卷清单摘要、manifest digest、阶段时间、执行身份、错误码、业务断言、RPO/RTO 计算、切流和清理结果。日志、截图和导出文件在入库前删除 Secret data、token、完整 kubeconfig、仓库签名 URL、客户数据与真实 endpoint。对数据水位保存哈希、序列或聚合结果,不复制整张业务表。
恢复仓库的读取、删除和保留修改要分权;演练身份默认只读。不可变存储防止对象被提前删除,却不能防止 KMS key 丢失,所以每轮要从目标身份实际解密。审计保留期由合规和事件响应要求决定,不能因为日志成本高就丢掉计算依据。
演练成本也要进入容量模型
恢复演练会产生临时集群、节点、恢复卷、快照、对象读取、跨区流量、KMS 请求、镜像拉取、日志索引和人员工时。控制成本的方法是使用脱敏 fixture、生命周期标签、预算告警和自动清理,不是减少关键验证或复用生产写入口。
容量趋势至少观察受保护字节、日变更率、恢复点数量、异地复制滞后、恢复吞吐、数据阶段 P50/P95、业务接管 P95、失败重试和孤儿资产。随着数据量增长,RTO 若线性恶化,应在越过目标前改变数据布局、并行策略或灾备模式,而不是等事故发生。
云产品的区域支持、配额和价格会变化,演练前在目标账号查当前能力与成本计算器。报告保存当次使用的配置和实际费用,不把临时单价写成长期事实。
清理后才算演练闭环
先撤销测试入口和外部回调,再停止工作负载,导出脱敏证据,删除恢复 namespace 与临时卷,核对快照、负载均衡器、IP、对象仓库和临时集群,最后撤销 IAM/KMS 授权。
kubectl --context "$TARGET_CTX" -n "$RESTORE_NS" scale deployment,statefulset --all --replicas=0
kubectl --context "$TARGET_CTX" -n "$RESTORE_NS" get all,pvc,configmap -o yaml \
> "${DRILL_ID}-final-inventory.yaml"
kubectl --context "$TARGET_CTX" delete namespace "$RESTORE_NS" --wait=true
velero --kubecontext "$TARGET_CTX" restore delete "$RESTORE_NAME" --confirm
kubectl --context "$TARGET_CTX" get pv,volumesnapshotcontents删除 Restore 不会删除恢复出的对象,因此 namespace 和后端资产必须单独核对。保留策略为 Retain 或仓库启用不可变时,清理记录要说明资产何时由生命周期回收。演练环境若接收过写入,销毁前按数据分类完成擦除或加密密钥撤销。
复盘只接受可执行改进:为缺失 CRD 增加目标基线检查;为 KMS 拒绝增加季度解密探针;为 data mover 长尾调整受测并发和节点容量;为人工查询编写自动业务断言;为审批延迟预签短期角色流程。每个改进有 owner、截止时间和下一次验证场景。
一个恢复点只有在隔离目标中被成功读取、重建、校验并完成业务接管后,才有资格进入 RPO 统计;一次演练只有在 RTO 能由原始时点重算、失败证据得到解释、临时权限和资产完成清理后,才真正结束。
演练实施入口
NIST Recovery Point Objective。NIST Recovery Time Objective。Velero Restore Reference
Velero Troubleshooting。Kubernetes ResourceQuota。Kubernetes Events
