☰
OpenStack Cinder备份自动化:cinderback增量链与恢复演练实践
2026/11/2 7:00:49 网站建设 项目流程

简介:cinderback 是面向 OpenStack 运维人员和云平台开发者的 Cinder 卷备份与还原辅助脚本,用 Python 编写,可批量处理租户卷备份,并针对 Cinder Backup 服务的现有局限提供变通方案。脚本支持按凭据覆盖全部租户,管理员替租户完成备份并可对原租户隐藏,具备备份轮换与保留数量限制、按卷 ID 或备份 ID 还原、保留原始卷名称和描述等能力;对使用中的卷,借助临时快照与卷的创建销毁完成备份,还能一次性导出或导入全部自动备份元数据。资源包共 4 个文件,以 cinderback.py 主脚本为核心,辅以 requirements.txt 依赖说明、README.md 使用文档和 .gitignore 配置,压缩后约 11KB,体量轻便,便于直接阅读、参考与二次改造。已有 315 人学习,适合希望理解备份脚本组织方式、快速搭建参考实现的中级读者。

1. Cinder 备份不是一条命令的事:cinderback 想解决的三个真问题

某公司的私有云上跑着几百个 Cinder 卷,早期备份靠运维手工敲cinder backup-create,谁备份了、谁漏了,全凭一张表格记。等到真出事要恢复时才发现,有三个卷的增量链断在半年前,父备份被清理脚本顺手删掉了。cinderback 就是在这个场景下写出来的 OpenStack Cinder 备份脚本助手:一个 Python 编排层,把建会话、批量建备份、轮询状态、落清单、恢复演练串成一条可重复执行的流水线。它不替换 Cinder 的 backup driver,也不碰底层存储,只是把 Cinder 已经提供的能力组织成任务。适合手里有几十个卷以上、需要按月出备份报表、又被增量链坑过一轮的云平台运维。

2. 拆开 cinderback 的调用链:会话、状态机与元数据清单

cinderback 干的事情拆开看就三件:用 keystoneauth1 拿一个带 microversion 的 cinderclient 会话,按卷清单批量提交 backup 请求,然后把返回的备份对象落成一份带状态和时间戳的清单。真正决定这套东西好不好用的,不是提交请求那几行代码,而是你对 Cinder 备份对象字段和状态机的理解程度。字段读错了,清理脚本就会删掉别人还依赖的父备份;状态判错了,报表上就会显示一堆「备份成功」的空壳。

2.1 建会话:microversion 与 endpoint_type 必须先定死

cinderclient 默认会拿服务端支持的最高版本,这在跨集群迁移脚本时会翻车——同一个脚本在 A 集群跑得通,到 B 集群因为某个字段不存在直接报 400。我的习惯是显式锁死一个够用又不激进的版本,把endpoint_type也写进配置,管理网内跑就用internalURL,别让流量绕一圈公网。

from keystoneauth1.identity import v3 from keystoneauth1 import session from cinderclient import client as cinder_client def build_cinder(conf): # 显式给 domain,避免多 domain 环境下 project_name 撞名 auth = v3.Password( auth_url=conf["auth_url"], username=conf["username"], password=conf["password"], project_name=conf["project_name"], user_domain_name=conf.get("user_domain_name", "Default"), project_domain_name=conf.get("project_domain_name", "Default"), ) # connect 短、read 长:建连失败要快速暴露,读超时要扛住大卷备份 sess = session.Session(auth=auth, timeout=(10, 180)) return cinder_client.Client( conf.get("api_version", "3.50"), session=sess, endpoint_type=conf.get("endpoint_type", "internalURL"), region_name=conf.get("region_name"), )

这段代码有三个参数值得掰开说。api_version传字符串形式,cinderclient 会用它去协商 microversion,写死之后字段行为就稳定了;但要注意别一味往高写,老集群的 cinder-api 未必支持,脚本启动时最好先打一次client.versions.get()之类的探测。timeout传元组,第一个是连接超时,第二个是读超时,读超时给到 180 秒是因为备份创建接口在部分后端上会同步等一段元数据落盘。endpoint_type选internalURL的前提是你的执行机在管理网段内,否则老老实实改publicURL。

还有一个容易被忽略的点:批量备份通常要用管理员身份跨租户拉卷清单。普通租户 token 调volumes.list(search_opts={"all_tenants": 1})只会返回自己的卷,不会报错,只是悄悄少了一半数据。这也是我建议在配置里显式写一个is_admin开关的原因——非管理员身份运行时,脚本应该主动把自己降级成「只备份当前项目」,而不是假装自己扫了全量。

2.2 备份对象的字段:排错时真正有用的是这七个

Cinder 返回的 backup 对象字段不少,但日常排错真正会盯的就那么几个。把它们记熟,比翻日志快得多。

字段含义排错时看什么
statuscreating / available / restoring / error / deleting卡在 creating 超过阈值即判定失败,别等它自己变
parent_id增量备份的父备份 ID为空说明是全量;断链排查从这里往下顺
snapshot_id备份来源的快照 ID有值说明走的是快照一致性路径,无值多半是活动卷直备
container备份落在哪个后端容器增量备份的 container 必须和父备份一致
size卷容量,单位 GiB恢复时目标卷不能小于这个值,注意是容量不是实际占用
data_timestamp数据时点判断 RPO 是否达标,比created_at更有意义
num_dependent_backups依赖它的子备份数量删父备份前必须确认为 0,这是最后一道闸

size这一栏特别容易误读。它记的是卷的声明容量,不是后端实际吃掉的字节数。Ceph 后端做增量备份时实际增量可能只有几百 MB,但size依然显示 100。做容量预测的时候拿size去乘数量会得到严重高估的结果,真正该看的是后端存储侧的用量,或者备份记录的object_count。

num_dependent_backups是清理环节的救命字段。Cinder 的增量备份是一条链,删中间一个节点会让后面所有子备份变成不可恢复的孤儿。有些版本的 API 在删除时不会硬拦,只是把子备份标成 error,等到你要恢复才发现。所以清理脚本里的第一道判断永远是「这个 backup 的num_dependent_backups是不是 0」,不是的话直接跳过并记一条日志。

2.3 编排:清单先落盘,再提交任务

cinderback 的骨架其实很朴素:先把要备份的卷拉成一份清单,过滤掉不需要的,再把每个卷映射成一条任务记录写到本地 JSON Lines 文件,最后才是并发提交。先落盘再提交这个顺序不能反,反了的话脚本中途挂掉,你根本不知道自己提交了哪些、哪些还在 creating。

import json from datetime import datetime, timezone def collect_targets(cinder, project_ids=None, skip_attached=False): # all_tenants 需要管理员权限,普通租户下会静默只返回自己的卷 volumes = cinder.volumes.list(search_opts={"all_tenants": 1}) targets = [] now = datetime.now(timezone.utc).isoformat() for v in volumes: if v.status not in ("available", "in-use"): continue # 正在扩容/迁移的卷跳过,避免快照失败 if skip_attached and v.attachments: continue # 业务低峰期也建议保留这个开关 if project_ids and v.os_vol_tenant_attr.tenant_id not in project_ids: continue targets.append({ "volume_id": v.id, "volume_name": v.name or "", "project_id": v.os_vol_tenant_attr.tenant_id, "size_gib": v.size, "attached": bool(v.attachments), "planned_at": now, "state": "planned", # planned -> submitted -> done/failed }) return targets def dump_targets(targets, path): # 一行一条,追加写;脚本重跑时按 volume_id + planned_at 去重 with open(path, "a", encoding="utf-8") as f: for t in targets: f.write(json.dumps(t, ensure_ascii=False) + "\n")

collect_targets里那个v.status not in ("available", "in-use")的判断是有意为之:卷处于reserved、restoring-backup、extending这些中间态时,快照大概率失败或者拿到脏数据,直接跳过比事后补录省事。skip_attached默认关掉是因为很多业务卷长期挂载,全跳过等于没备份,但它必须作为一个显式开关存在,不能写死在代码里。

dump_targets用追加写而不是覆盖写,为的是保留历史批次。重跑时的去重键建议用volume_id + planned_at的组合,而不是单看volume_id——同一个卷在不同批次里本来就该被备份多次。

3. 把备份跑起来:增量策略、一致性开关与并发控制

任务清单有了,接下来是这批任务怎么提交。这里有三个参数组合会直接决定备份能不能用:全量还是增量、走不走快照、并发开多大。它们之间还互相牵制,增量需要父备份可用,快照需要卷支持,并发要受后端吞吐限制。这一章把常见取值和判断依据摊开讲。

3.1 全量与增量:先问父备份还活着没

增量备份的前提是有一个可用的父备份,而且父备份的container和当前要用的后端一致。如果父备份已经被清理或者状态是 error,incremental=True提交上去要么直接 400,要么创建一个依赖坏父备份的子备份,后者更麻烦——它看起来是成功的。所以提交前的检查顺序是:先按卷找最近一个status == "available"且num_dependent_backups链完好的备份,再校验 container 一致,最后才决定这次走增量还是退回全量。

def pick_strategy(cinder, volume_id, container, max_chain=30): # 按时间倒序找最近一次可用备份作为潜在父备份 backups = cinder.backups.list(search_opts={ "volume_id": volume_id, "status": "available", "sort_key": "created_at", "sort_dir": "desc", }) if not backups: return {"incremental": False, "parent_id": None, "reason": "no_parent"} parent = backups[0] if parent.container != container: return {"incremental": False, "parent_id": None, "reason": "container_mismatch"} # 链路太长就强制做一次全量,避免恢复时逐层回放开销 chain_len = count_chain(cinder, parent) if hasattr(cinder, "x") else 1 if chain_len >= max_chain: return {"incremental": False, "parent_id": None, "reason": "chain_too_long"} return {"incremental": True, "parent_id": parent.id, "reason": "ok"}

max_chain这个阈值是血泪经验换来的。增量链拉到五六十层之后,恢复要逐层合并,一次恢复能跑几个小时,中间任何一层的后端读取抖动都会让整个恢复失败重来。我一般把上限定在 20 到 30 之间,到线就插一次全量,代价是存储多占一份,收益是恢复时间可控。

container校验不能省。同一套 Cinder 背后可能挂了 Ceph 和 NFS 两个备份后端,卷的增量备份跨后端是不成立的。有些环境里 container 是自动分配的,那就得从配置里读死,让脚本显式传container=参数,别指望服务端帮你挑。

3.2 一致性:先冻结再快照,最后才备份

直接对活动卷做 backup,拿到的是某个瞬间的块设备镜像,文件系统层面未必一致。合格的做法是先让业务侧安静下来,再做快照,再从快照建备份。Linux 上常见做法是在实例内跑fsfreeze,或者通过 qemu-guest-agent 让虚拟化层发起冻结;有集中式存储的环境也可以用存储侧的一致性组快照。cinderback 这一层能做的,是把「先建快照、再按 snapshot_id 建备份」这条路径固化下来。

# 实例内:冻结文件系统,刷盘后返回 sudo fsfreeze --freeze /data # 平台侧:基于卷建快照,再按快照建备份 cinder snapshot-create --force True --name db-vol-snap-20240612 <volume-id> cinder backup-create --snapshot-id <snapshot-id> --name db-vol-bak-20240612 <volume-id> # 备份状态进入 available 后解冻 sudo fsfreeze --unfreeze /data

--force True是针对挂载中的卷,不加这个参数对 in-use 卷建快照会直接拒绝。冻结窗口要尽量短,实务上控制在秒级:先在平台上把快照建好,再解冻,让备份从快照慢慢跑,而不是把业务冻在备份全过程里。这个顺序反了,业务冻结时间会从几秒变成几十分钟,DBA 会来找你。

如果不方便做实例内冻结,退一步的做法是挑业务低峰、并且接受「崩溃一致」而非「应用一致」。这时候 backup 记录里的snapshot_id会是空的,排错和审计时要从data_timestamp反推数据时点。

3.3 并发与限速:别把 cinder-api 和存储一起打满

几百个卷一次性提交,最先扛不住的不是存储,是 cinder-api 的 worker 和消息队列。我的经验值是并发 4 到 8 个提交线程,配合一个提交间隔,让请求均匀铺开。存储侧如果是 Ceph,还要额外考虑同一时间大量 rbd 快照对 OSD 的压力。

import time from concurrent.futures import ThreadPoolExecutor, as_completed def submit_batch(cinder, targets, workers=6, gap=1.5, dry_run=False): results = [] def one(t): strat = pick_strategy(cinder, t["volume_id"], t.get("container", "cinder-backup")) if dry_run: return {**t, "action": "dry_run", **strat} try: b = cinder.backups.create( t["volume_id"], name=f"{t['volume_name'] or t['volume_id']}-{t['planned_at'][:10]}", incremental=strat["incremental"], force=True, # 允许对 in-use 卷建备份 container=t.get("container", "cinder-backup"), ) return {**t, "backup_id": b.id, "state": "submitted", **strat} except Exception as e: return {**t, "state": "failed", "error": str(e)[:200], **strat} with ThreadPoolExecutor(max_workers=workers) as pool: futures = [pool.submit(one, t) for t in targets] for f in as_completed(futures): results.append(f.result()) time.sleep(gap) # 提交节流,避免瞬时打满 api worker return results

force=True是给挂载卷开的口子,代价是备份的一致性由你自己负责——如果前面没做冻结或快照,这个备份就是崩溃一致的。gap这个节流参数看着土,但在中小规模集群上比任何限流中间件都直接有效。dry_run分支一定要保留,它让你能在真正提交前把「哪些卷会走增量、哪些会退回全量、原因是什么」全部打印出来对一遍,这一步能挡掉大半配置错误。

container传参要注意:不同环境的容器名不一样,Ceph 环境常见是cinder-backup,NFS 环境可能是自定义名字。写死之前先cinder backup-list看一眼已有备份的 container 值。

3.4 结果落盘与幂等重跑

提交完不等于结束。每批任务的结果要写回清单,字段至少包含backup_id、state、strategy、error。重跑的时候按volume_id + planned_at找到同批次记录,state是submitted或done的直接跳过,failed的才重新提交。没有这层幂等,一次网络抖动导致的重跑就会给同一个卷建出两三份备份,配额很快见底。

另外建议在清单里单独记一列「本次批次的指纹」,用卷 ID 加计划时间做哈希。这个指纹在后续的报表、清理、恢复演练里都能当主键用,比备份名可靠——备份名是人起的,可能重复,指纹不会。

4. 恢复演练:把 backup-id 验成真正能用的数据

备份任务的状态变成available,只说明对象写进去了,不说明它一定能恢复成可用的卷。这一章讲怎么把「备份成功」推进到「恢复成功且数据对得上」。很多团队卡在这一步,报表上备份全是绿的,真到演练时发现恢复出来的卷挂不上或者文件系统坏掉。

4.1 轮询状态:卡在 creating 就要主动判死

cinderclient 的backups.create返回是异步的,拿到 ID 之后必须轮询。轮询不能只看终态,还得设一个超时上限——部分后端在对象写失败时会一直停在creating,不会自己转error。按卷大小估算阈值比较靠谱,比如 100 GiB 以内给 40 分钟,超过就按每 100 GiB 加 10 分钟。

import time def wait_backup(cinder, backup_id, timeout=2400, interval=20): deadline = time.time() + timeout last = None while time.time() < deadline: b = cinder.backups.get(backup_id) if b.status != last: last = b.status # 状态翻转时落一条日志,方便事后看时间线 print(f"{backup_id} -> {b.status}") if b.status == "available": return b if b.status == "error": raise RuntimeError(f"backup {backup_id} error: {getattr(b, 'fail_reason', '')}") time.sleep(interval) raise TimeoutError(f"backup {backup_id} stuck in {last}")

interval别设太小,20 秒一次足够,轮询本身也会打 cinder-api。fail_reason这个字段不是所有版本都有,取不到就退回看服务端日志,但日志里往往只有一句「backup driver failed」,真正的线索在后端存储侧的对象存储日志里。

4.2 恢复到新卷并校验数据

恢复的目标卷大小不能小于备份的size,这是硬约束。演练时建议统一恢复到新卷并加上标记前缀,避免误覆盖生产卷。恢复完成后把新卷挂到一台校验机上,跑文件级校验。

# 建一个同容量或更大的空白卷,指定与备份兼容的 AZ cinder create --size 100 --availability-zone nova --name drill-restore-20240612 # 从备份恢复到该卷,恢复期间卷状态为 restoring-backup cinder backup-restore --volume <new-volume-id> <backup-id> # 挂载到校验机后比对关键目录的文件清单与校验和 ssh verify-host "mkdir -p /mnt/drill && mount /dev/vdb /mnt/drill" ssh verify-host "cd /mnt/drill && find . -type f -size +1M | head -200 | xargs sha256sum" > /tmp/after.txt diff /tmp/before.txt /tmp/after.txt

--availability-zone要和备份所在的 AZ 对得上,跨 AZ 恢复在部分部署里会因为后端不可达直接失败。校验环节不一定非要全量比对,抽样 200 个大文件加一份目录树清单已经能覆盖绝大多数损坏场景。真正需要强校验的是数据库卷,那种情况建议直接fsck加一次应用层启动验证。

4.3 演练节奏:每季度抽一批,别等出事才跑

演练不需要全量。我的做法是每季度从最近三个月的备份里按项目分层抽一批,覆盖不同后端、不同卷大小、不同备份类型(全量/增量)。抽到的才做完整恢复校验,没抽到的只做元数据检查:status是否为 available、父链是否完整、container是否还存在。这样成本可控,又能持续暴露链路问题。

演练结束后要写回清单,记下restored_volume_id、校验结果、耗时。耗时这一列在容量规划时很有用——恢复一个 1 TiB 卷要多久,决定了你真正的 RTO,不是备份成功的时间。

5. 避坑与排查:五类翻车现场

这一章把我在实际环境里踩过、也帮别人排查过的坑列一遍。每条按「现象、原因、解决」写,方便对照。

坑一:备份全部停在 creating,几小时不动。现象是backup-list里一整批都是 creating,fail_reason为空。原因通常是备份后端(对象存储或 NFS 挂载点)连不上,backup driver 在重试而不是报错。解决方式是先去 cinder-backup 服务的日志里找 driver 级别的报错,再验证后端挂载或对象存储桶的可写性;顺带确认 cinder-backup 服务本身是不是只剩一个实例在跑、消息队列有没有积压。

坑二:增量备份提交后立刻变 error,报父备份不可用。现象是incremental=True提交即失败。原因多半是选中的父备份其实已经不是 available,或者container和当前配置不一致。解决方式是把选父备份的逻辑改成只认status == available且 container 匹配的记录,找不到就强制走全量。这里有个隐蔽分支:父备份状态是 available 但它的祖先被删了,链是断的,这种情况只能靠定期扫描parent_id链发现。

坑三:清理脚本删完父备份,子备份第二天全变 error。现象是清理后一段时间,一批历史备份集体失效。原因是删除时没检查num_dependent_backups。解决方式很简单,删之前判断该字段为 0,不为 0 就跳过并输出提示;同时清理策略改成从最老的开始、按链整段删,而不是按时间点切片删。

坑四:恢复时报目标卷太小。现象是backup-restore直接拒绝。原因是目标卷容量小于备份记录里的size,而size是卷的声明容量,不是实际数据量。解决方式是建卷时严格按备份size取值,宁可多给也不能少给;如果卷是从更大的源克隆来的,历史备份的size可能比现在还大,按最大值取。

坑五:脚本重跑给同一个卷建了两份备份,配额告警。现象是配额突然打满,一看同一个卷两个小时内有两份备份。原因是清单去重键只用了volume_id,重跑逻辑没识别出上一批已提交。解决方式是把去重键换成volume_id + planned_at,并且提交前先查一次最近 2 小时内是否已有同卷的 submitting 或 available 备份,有就跳过。

注意:这五条里前三条都指向同一件事——备份对象的元数据必须当成一等公民来维护,不能只把它当作建完就忘的操作结果。

6. 进阶:把备份清单做成链路索引与容量视图

清单如果只是每次跑完追加几行 JSON,用起来会很别扭。真正让它产生价值的是把它当索引来查,具体两个用法。

第一是链路口径的清理。把清单按volume_id分组,每组按created_at排序,就能还原出每个卷当前的备份链结构。清理时不再按「N 天前的删掉」这种粗暴规则,而是从链尾往前数,保留最近 K 层,同时保证被保留的最老那一层必须是全量。这个约束一加,链就不会出现「最底层是增量、父备份早被删」的孤儿。

第二是容量与 RPO 视图。按项目聚合size能得到备份配额占用,按data_timestamp的分布能看出哪些卷已经超过 RPO 阈值没备份了。我一般每周导一次报表,把超过 48 小时没有可用备份的卷单独列出来,这类卷在真实故障里就是最贵的。

from collections import defaultdict def build_index(records): idx = defaultdict(list) for r in records: if r.get("backup_id") and r.get("state") in ("done", "available"): idx[r["volume_id"]].append(r) for vid, chain in idx.items(): chain.sort(key=lambda x: x.get("created_at", "")) return idx def safe_cleanup(idx, keep_latest=7): plan = [] for vid, chain in idx.items(): keep = chain[-keep_latest:] # 保留段的第一条必须是全量,否则整段往后挪一位或直接插一次全量 while keep and keep[0].get("parent_id"): keep = keep[1:] plan.extend(keep) return plan

这段里的while keep and keep[0].get("parent_id")是整个清理逻辑的关键,它在保证「保留段起点是全量」这件事上比任何注释都可靠。keep_latest不是越大越好,7 层够应付大多数误删场景,再多就要考虑恢复耗时了。

从那以后我每次改清理规则,都会先在 dry-run 模式下把「将删除哪些、为什么删、保留段的起点是否全量」完整打印一遍,人工扫十条记录才敢开真删。这套习惯救过我一次——某次阈值调低之后,有三条链的起点会变成增量,dry-run 一眼就看出来了。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询