应用一致性、Hook 与数据冻结
恢复演练已经走到最后一步:新 PVC 是 Bound,Pod 是 Ready,数据库也能启动,可是订单表有一笔新记录,审计表却没有对应流水。存储团队确认快照成功,数据库团队确认文件没有损坏,业务仍然无法接管。问题不在“有没有快照”,而在快照落下的那个瞬间,应用正处于只有自己才懂的中间状态。
更危险的现场发生在失败路径。备份任务先让写入进入维护状态,随后 CSI 快照超时;编排器直接结束 Job,没有执行解冻动作。快照没得到,线上写入也一直停着。此时重跑备份只会扩大停写时间,删除 Job 也不会自动撤销应用内部状态。pre hook 能冻结,post hook 就必须在成功、失败、超时和人工中断后都能解冻。
先分清三种一致性
Crash consistency 模拟的是某一瞬间断电后磁盘可见的状态。文件系统和数据库可能依靠 journal、WAL 或 redo 在启动时修复物理结构,但它不承诺跨事务、跨进程、跨卷或跨外部系统的业务不变量。一个数据库文件通过 integrity_check,并不等于订单、库存和消息投递已经彼此对应。
Filesystem consistency 关注文件系统元数据与写入顺序。fsfreeze 可以阻止新的文件系统修改并把脏数据推进到底层设备,但它不知道数据库缓冲池、应用内存队列和远端 API。直接冻结一个仍在高频写入的数据库挂载点,还可能让进程阻塞、探针超时和故障切换同时发生。
Application consistency 由应用协议定义。它通常要求停止接收新写入,等待在途工作完成,执行数据库 checkpoint 或 flush,记录日志位置和业务水位,再触发卷快照;恢复后还要用同一组业务不变量证明结果。Kubernetes VolumeSnapshot只提供 CSI 快照对象与生命周期,不替应用执行这些动作。
接收写入 -> 内存/事务/WAL -> 文件系统 -> 块设备 -> 后端快照
| | | |
业务水位 checkpoint freeze CSI CreateSnapshot越靠右的动作越不了解左侧语义。只做后端快照,最多继承存储和驱动给出的 crash-consistent 能力;要得到应用一致性,必须从左向右收敛,再从右向左验证和恢复服务。
操作前先确认谁能完成每一步
集群应已经具备可工作的 VolumeSnapshot API、集群级 snapshot controller、CSI driver 的 snapshot sidecar,以及可用于测试 PVC 的 VolumeSnapshotClass。不要根据 CRD 存在就认定驱动支持快照;先查看实际对象和驱动名称:
kubectl version
kubectl api-resources | grep -E 'volumesnapshot|volumegroupsnapshot'
kubectl get volumesnapshotclass
kubectl get csidriver
kubectl get pods -A | grep -E 'snapshot-controller|csi-snapshotter'预期至少能看到 snapshot.storage.k8s.io/v1 的三类对象、目标 CSI driver 和一个明确的 class。若 VolumeSnapshot 可创建却长期没有 readyToUse: true,优先查看 snapshot、content 的 Event 和对应 sidecar 日志,而不是继续设计 hook。
安装物必须从 Kubernetes 发行版、托管服务或 CSI driver 的支持矩阵选择。上游 external-snapshotter 的源码 tag、容器、CRD、controller 和 sidecar 可能不是同一发布节奏,直接拼接“最新”清单会得到 API 存在但运行链不闭合的组合。external-snapshotter releases和驱动厂商矩阵应一起核对。
执行身份至少拆成两部分:
快照身份只能读取目标 PVC、创建和观察目标 namespace 的 VolumeSnapshot;集群级 content 与后端凭证由 snapshot controller 和 CSI sidecar 持有。hook 身份只能对指定 Pod 执行受控动作,或调用应用暴露的维护 API;它不应因此获得任意 Pod exec、Secret 读取或集群管理员权限。
使用 Velero 编排动作时,应按 Velero v1.18 Backup Hooks 的 init-container 与 exec hook 语义设置容器、命令、错误策略和超时;工具只能负责触发与记录,应用协议仍决定何时真正达到可快照状态。
数据库凭证不要写进 hook 参数、ConfigMap 或日志。优先让应用自身提供带审计的 quiesce 接口,或让专用 ServiceAccount 通过工作负载身份取得短期凭证。必须使用 Secret 时,只挂载到执行 hook 的容器,限制读取主体,并演练轮换后下一轮备份是否恢复。
用一个可复现故障看见中间状态
下面的隔离实验使用一个 SQLite PVC 模拟“业务记录先提交,审计流水后提交”。SQLite 只是让中间状态容易观察;生产数据库应换成该产品正式支持的 backup、checkpoint 或只读协议。镜像进入团队模板前应固定 digest,实验中的 tag 只用于说明命令结构。
先创建 namespace、PVC、脚本和测试 Pod:
apiVersion: v1
kind: Namespace
metadata:
name: consistency-lab
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: app-data
namespace: consistency-lab
spec:
accessModes: [ReadWriteOnce]
resources:
requests:
storage: 1Gi
---
apiVersion: v1
kind: ConfigMap
metadata:
name: consistency-hooks
namespace: consistency-lab
data:
writer.sh: |
#!/bin/sh
set -eu
apk add --no-cache sqlite util-linux
sqlite3 /data/app.db 'PRAGMA journal_mode=WAL; CREATE TABLE IF NOT EXISTS orders(id INTEGER PRIMARY KEY, payload TEXT); CREATE TABLE IF NOT EXISTS audit(order_id INTEGER PRIMARY KEY);'
while true; do
if [ -e /data/.quiesce ]; then sleep 1; continue; fi
exec 9>/data/app.lock
flock -x 9
if [ -e /data/.quiesce ]; then flock -u 9; sleep 1; continue; fi
id="$(sqlite3 /data/app.db \"INSERT INTO orders(payload) VALUES('accepted'); SELECT last_insert_rowid();\")"
sleep 5
sqlite3 /data/app.db "INSERT INTO audit VALUES($id);"
flock -u 9
sleep 1
done
pre.sh: |
#!/bin/sh
set -eu
exec 9>/data/app.lock
flock -x 9
touch /data/.quiesce
bad="$(sqlite3 /data/app.db 'SELECT count(*) FROM orders o LEFT JOIN audit a ON a.order_id=o.id WHERE a.order_id IS NULL;')"
test "$bad" = "0"
sqlite3 /data/app.db 'PRAGMA wal_checkpoint(TRUNCATE);'
sqlite3 /data/app.db 'SELECT count(*) AS orders FROM orders;'
sqlite3 /data/app.db 'SELECT count(*) FROM orders;' > /data/.checkpoint-position
post.sh: |
#!/bin/sh
set -eu
rm -f /data/.quiesce
test ! -e /data/.quiesce
---
apiVersion: v1
kind: Pod
metadata:
name: ledger
namespace: consistency-lab
labels:
app: consistency-lab
spec:
containers:
- name: app
image: alpine:3.22
command: ["/bin/sh", "/hooks/writer.sh"]
volumeMounts:
- name: data
mountPath: /data
- name: hooks
mountPath: /hooks
volumes:
- name: data
persistentVolumeClaim:
claimName: app-data
- name: hooks
configMap:
name: consistency-hooks
defaultMode: 0755保存为 consistency-lab.yaml 后执行:
kubectl apply -f consistency-lab.yaml
kubectl -n consistency-lab wait pod/ledger --for=condition=Ready --timeout=180s
until kubectl -n consistency-lab exec ledger -- \
sqlite3 /data/app.db 'SELECT count(*) FROM orders;' >/dev/null 2>&1; do sleep 2; done
kubectl -n consistency-lab logs ledger --tail=20首次启动需要下载包。生产镜像应在构建阶段放入工具并固定 digest,避免备份窗口依赖外网和软件仓库。Pod 进入 Ready 后,等待一个写入周期,再查看业务不变量:
kubectl -n consistency-lab exec ledger -- sh -c \
"sqlite3 /data/app.db 'SELECT (SELECT count(*) FROM orders) AS orders, (SELECT count(*) FROM audit) AS audit;'"稳定时两列相等;每个周期中间有约五秒窗口,orders 会暂时比 audit 多一。这个窗口正是只靠 crash-consistent 快照可能固化的应用中间状态。
反向实验:快照成功,业务校验失败
先把实际 class 写入环境变量,并在观察到计数不相等时创建快照。以下命令要求当前身份只在实验 namespace 操作:
export SNAPSHOT_CLASS='<your-snapshot-class>'
until kubectl -n consistency-lab exec ledger -- sh -c \
"test \$(sqlite3 /data/app.db 'SELECT (SELECT count(*) FROM orders)-(SELECT count(*) FROM audit);') -gt 0"; do
sleep 1
done
# 在两次业务提交之间模拟进程崩溃,使错误状态稳定留在 PVC 上。
kubectl -n consistency-lab delete pod ledger --wait=true
cat <<EOF | kubectl apply -f -
apiVersion: snapshot.storage.k8s.io/v1
kind: VolumeSnapshot
metadata:
name: crash-point
namespace: consistency-lab
spec:
volumeSnapshotClassName: ${SNAPSHOT_CLASS}
source:
persistentVolumeClaimName: app-data
EOF
kubectl -n consistency-lab wait volumesnapshot/crash-point \
--for=jsonpath='{.status.readyToUse}'=true --timeout=10m
kubectl -n consistency-lab get volumesnapshot crash-point -o yaml预期证据是 readyToUse: true、绑定的 VolumeSnapshotContent、创建时间、恢复大小和后端 handle。它只证明 CSI 认为该快照可用于恢复。接着从快照创建新 PVC:
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: crash-restore
namespace: consistency-lab
spec:
storageClassName: <compatible-storage-class>
dataSource:
name: crash-point
kind: VolumeSnapshot
apiGroup: snapshot.storage.k8s.io
accessModes: [ReadWriteOnce]
resources:
requests:
storage: 1Gi将它挂到不接生产流量的校验 Pod,执行:
sqlite3 /restore/app.db 'PRAGMA integrity_check;'
sqlite3 /restore/app.db 'SELECT count(*) FROM orders o LEFT JOIN audit a ON a.order_id=o.id WHERE a.order_id IS NULL;'第一条应返回 ok,第二条应返回 1。物理可打开与业务一致性由两条不同证据证明;若只保留 readyToUse 和数据库启动日志,恢复评审就会错过真正的损坏。
正向实验:停写、收敛、checkpoint、快照、解冻
正确顺序不是“先 checkpoint 再想办法停写”。checkpoint 之后若仍接受事务,新的 WAL 和业务中间状态会立刻出现。实验的 pre.sh 先取得应用锁,设置停写标记,等待当前逻辑工作完成,再检查不变量并执行 WAL checkpoint。
先重新创建测试 Pod,并修复刚才故意留下的孤立订单。这个修复只为重置实验夹具;真实恢复遇到同类结果时,应停止接管并执行经过业务审批的对账或补偿。
kubectl apply -f consistency-lab.yaml
kubectl -n consistency-lab wait pod/ledger --for=condition=Ready --timeout=180s
until kubectl -n consistency-lab exec ledger -- \
sqlite3 /data/app.db 'SELECT count(*) FROM orders;' >/dev/null 2>&1; do sleep 2; done
kubectl -n consistency-lab exec ledger -- sh -c \
"sqlite3 /data/app.db 'INSERT OR IGNORE INTO audit(order_id) SELECT id FROM orders;'"kubectl -n consistency-lab exec ledger -- /hooks/pre.sh
kubectl -n consistency-lab exec ledger -- sh -c \
"test -e /data/.quiesce && sqlite3 /data/app.db 'PRAGMA wal_checkpoint; SELECT (SELECT count(*) FROM orders)-(SELECT count(*) FROM audit);'"预期最后一个数字为 0,并存在 .quiesce 与 .checkpoint-position。此时再创建名为 application-point 的 VolumeSnapshot,等待 readyToUse: true,无论快照结果如何都执行 post:
cat <<EOF | kubectl apply -f -
apiVersion: snapshot.storage.k8s.io/v1
kind: VolumeSnapshot
metadata:
name: application-point
namespace: consistency-lab
spec:
volumeSnapshotClassName: ${SNAPSHOT_CLASS}
source:
persistentVolumeClaimName: app-data
EOF
set +e
kubectl -n consistency-lab wait volumesnapshot/application-point \
--for=jsonpath='{.status.readyToUse}'=true --timeout=10m
snapshot_rc=$?
kubectl -n consistency-lab exec ledger -- /hooks/post.sh
post_rc=$?
set -e
test "$snapshot_rc" -eq 0
test "$post_rc" -eq 0恢复 application-point 后,至少检查 SQLite 物理完整性、未配对订单数为零、checkpoint 水位存在,并观察应用能继续创建成对记录。生产应用还应校验 schema、日志回放位置、复制角色、关键查询和外部副作用。Pod Ready 只能证明进程通过探针。
Hook 失败时,解冻是独立的补偿事务
常见错误是把动作写成 pre && snapshot && post。只要 pre 的后半段、快照或等待命令失败,shell 就跳过 post。更稳妥的编排把状态显式保存,并在退出处理器中尝试解冻:
set -u
frozen=0
thaw() {
if [ "$frozen" -eq 1 ]; then
kubectl -n consistency-lab exec ledger -- /hooks/post.sh
fi
}
trap thaw EXIT INT TERM
kubectl -n consistency-lab exec ledger -- /hooks/pre.sh
frozen=1
kubectl -n consistency-lab wait volumesnapshot/application-point \
--for=jsonpath='{.status.readyToUse}'=true --timeout=10m
thaw
frozen=0补偿动作必须幂等:重复 post 仍然成功,Pod 重启后也能判断是否冻结。仅依赖编排进程内的 frozen=1 不够,真实状态要由应用 API、数据库只读标志、冻结文件、租约或带过期时间的维护记录表达。hook runner 消失后,另一个恢复者才能据此接管。
再做一次稳定反例:把 pre 脚本临时改成设置 .quiesce 后返回非零,确认备份不会创建 application-point,退出处理器会删除标记,计数继续增长。若应用仍冻结,使用受控的 break-glass 动作:先确认没有快照 RPC 正在读取冻结点,再执行 post,记录操作者、原因和恢复后的首个成功写入。不要直接重启所有 Pod;冻结状态可能位于数据库或外部协调服务中,重启未必清除,反而会制造 leader 切换。
fsfreeze 只能放在协议里,不能替代协议
某些文件系统与存储组合会在应用 quiesce 之后执行 fsfreeze -f <mount>,快照完成后执行 fsfreeze -u <mount>。执行容器需要看到真实挂载点并拥有足够的系统权限,这通常意味着高权限、host mount 或 CSI/存储集成,风险显著高于调用应用 API。
采用它时应同时满足:文件系统和 CSI driver 明确支持;应用已经停写并 flush;freeze 有短超时;thaw 在独立失败路径执行;探针和故障切换不会把短暂停顿误判为实例死亡。验证命令应在隔离卷完成:
timeout 15s fsfreeze -f /data
# 只在冻结成功后触发快照,并记录开始时间。
fsfreeze -u /data
findmnt -no TARGET,FSTYPE,OPTIONS /datatimeout 杀掉 freeze 命令不等于文件系统已经解冻。超时后仍要显式执行 fsfreeze -u 并验证写入恢复。若平台通过 CSI 驱动或备份产品封装 freeze/thaw,应读取它的失败语义和日志位置,不能再叠加第二套人工 freeze。
多副本、多卷和 Operator 会改变状态机
单 Pod 的 exec hook 很容易在 leader 切换后对错实例执行。多副本应用应先解析当前写 leader,再把 leader UID、数据库角色和复制水位写入恢复点元数据;pre 与 post 要验证自己操作的是同一代实例。若 pre 后 Pod 被重建,新 Pod 不能盲目继承“已冻结成功”的结论。
多卷快照把多个卷推进到同一存储时间点,仍不自动获得数据库事务一致性。应用应先固定成员 PVC 的 name、UID、PV、driver、volume handle 和角色,完成 quiesce 后再发起 group snapshot;恢复时逐一映射 data、WAL、index 等角色。成员遗漏或 selector 误选必须让任务失败,不能把部分成功标成 application-consistent。
数据库 Operator 往往比通用 exec hook 更了解 leader、checkpoint、备份锁和日志归档。优先级通常是:数据库原生备份/Operator 工作流,应用维护 API,受控 exec hook,最后才是通用文件系统冻结。选择标准不是“命令最短”,而是谁能证明状态收敛、失败补偿和恢复后的业务不变量。
容量与成本由停写窗口和副本链共同决定
应用一致性会增加备份延迟。总窗口至少包含停写收敛、checkpoint/flush、快照创建、后端可用确认和解冻;copy-on-write 快照之后的写放大会继续占用存储性能。高峰期 checkpoint 可能制造 I/O 尖峰,多个 namespace 同时冻结会把延迟集中到同一分钟。
容量测试应记录持续写入速率、在途事务数、checkpoint 字节量与耗时、freeze 时长、snapshot ready 时长、解冻后积压回补速度、恢复日志回放量和新卷创建时间。阈值来自业务 SLO、存储基线和 RPO/RTO,不要复制固定秒数。若无法把停写窗口压进 SLO,应改用数据库在线备份、复制副本备份或连续日志归档,而不是放宽 hook 超时后继续把结果标成一致。
成本不只有快照容量。还包括快照增量链、恢复校验卷、隔离计算、跨区域传输、日志保留、数据库副本和演练窗口。云服务价格与支持能力会变化,选型时从对应产品的官方定价与限制页按实际区域、存储类型、保留期和恢复频率测算,不在模板中固化单价。
升级与退出要保留可逆路径
升级备份平台、CSI driver、数据库或 Operator 时,先在隔离 namespace 双跑旧、新 hook。比较调用目标、超时、重试、Pod 选择、凭证身份、checkpoint 位置、快照对象、恢复不变量和 post 的幂等性。新版本若改变 hook 执行容器、shell、默认超时或失败是否继续,必须视为协议变化,而不是普通镜像替换。
回滚时恢复整套 hook 定义、执行身份和超时策略,并确认没有旧任务仍持有冻结租约。退出某个备份工具前,先停止新调度,等待运行中任务结束或补偿,导出仍需保留的恢复点与应用水位,再删除 hook RBAC、临时 Secret、测试 PVC 和恢复副本。最后对所有受保护应用执行一次“当前为可写、没有遗留冻结、下一种保护方式已产生可恢复点”的核对。
实验资源可按依赖顺序清理:先删除校验 Pod 和恢复 PVC,再按 VolumeSnapshotContent.spec.deletionPolicy 判断删除 snapshot 是否会删除后端副本,最后删除源 Pod、PVC、ConfigMap 和 namespace。若 class 使用 Retain,删除 namespaced snapshot 后后端资产不会自动消失,必须按 handle 在存储侧完成归属和清理。
kubectl -n consistency-lab delete pod restore-check --ignore-not-found
kubectl -n consistency-lab delete pvc crash-restore application-restore --ignore-not-found
kubectl -n consistency-lab get volumesnapshot -o wide
kubectl get volumesnapshotcontent
kubectl delete namespace consistency-lab最终应同时留下四类证据:应用确实停止接收新写入,在途工作和 checkpoint 确实收敛,快照确实能恢复到新卷,成功与失败路径都确实恢复可写。少任何一类,application-consistent 都只是标签。
