OpenStack Cinder 备份自动化脚本 cinderback 实践 简介这份资源是面向 OpenStack 运维人员与云计算学习者的 Cinder 卷备份脚本助手以 Python 编写的示例脚本形式填补 Cinder Backup Service 在租户可见性、批量轮换等环节的能力缺口可作为二次开发与日常运维的参考模板。压缩包共 4 个文件约 11KB包含核心 Python 脚本、依赖清单、说明文档及忽略配置体积轻量、结构直接便于阅读源码与快速部署。目前已有 315 人学习下载说明其在相关运维场景中具备一定参考价值。脚本支持按凭据对单租户或全部租户的卷执行备份与还原管理员可选择向卷所有者隐藏备份并能便捷恢复后再调整可见性同时提供按卷限制自动备份保留数量的轮换机制支持通过原始卷 ID 或备份 ID 还原、可选保留原卷名称与描述借助临时快照完成在用卷的备份与卷创建销毁流程还可将所有自动备份元数据一次性导出或导入单一文件。运行仅依赖 Python 2.7 与 cinderclient 1.1.1 以上版本便于读者据此搭建贴合自身环境的备份还原工作流。1. 凌晨两点那一通电话让我重新看了一遍备份脚本去年某个周二凌晨值班电话响起来说某业务盘的逻辑损坏需要回滚。等我登录控制台翻备份列表才发现最近一周的备份记录是空的因为负责点备份的同事休假了手动流程断了。那一刻我才意识到OpenStack Cinder 备份这件事API 早就有了缺的是把备份这个固定动作自动编排起来的一层脚本。cinderback 就是干这个的一个用 Python 写的 Cinder 备份脚本助手把“选卷、打快照、发起备份、轮询状态、清理旧备份”串成几条命令和一份 cron。它不替换 cinder-backup 服务也不碰后端存储专门管控制面。适合已经在跑私有云、卷数量上了三位数、还靠人工点控制台的中小团队运维和存储方向开发。2. 项目定位与技术栈cinderback 在备份链路里补的是哪一块先明确一件事Cinder 本身有完整的备份能力。cinder-backup服务接 Swift、Ceph、NFS 做后端存储API 层有backup-create、backup-restore、backup-delete。所以看到“备份脚本助手”几个字别误以为它重写了一套备份引擎那样既没必要也跑不通。cinderback 的定位在控制面编排。它站在 Cinder API 之上替人完成那些重复且容易漏掉的动作按标签或名字正则挑出该备份的卷、判断这次做全量还是增量、发起快照和备份、盯着状态直到 available、按保留策略清掉过期备份、最后输出一份能贴进值班日报的结果。换句话说它管的是“什么时候对谁做什么”底层数据落到哪儿仍然由 Cinder 和后端存储决定。这个边界划清楚之后技术选型就很好理解了。2.1 为什么是 Python而不是 Shell 加 curl纯 Shell 写备份脚本头一百行很爽到第三百行就开始痛苦。要解析 JSON、要算时间差、要并发、要重试每一件事都是一次新的翻车机会。这个项目选 Python核心原因是openstacksdk把 Keystone 鉴权、服务目录、请求重试、分页都包好了自己只需要写业务逻辑。常见的技术栈组合是这样层选型作用语言运行时Python 3.8主语言类型注解可选OpenStack 客户端openstacksdk底层 keystoneauth1鉴权、卷/快照/备份 API配置PyYAML一份 yaml 管多环境命令行click 或 argparse子命令式 CLI重试与退避tenacity 或手写循环409/503 这类瞬时错误日志标准库 logging结构化输出方便接采集我一般会在requirements.txt里把版本卡死到次版本号因为 openstacksdk 的小版本升级偶尔会动参数名线上脚本最怕这种玄学问题。这不是过度谨慎是踩过的坑。2.2 目录结构模块职责怎么切一个能长期维护的备份脚本不该是一个两千行的单文件。按职责切分之后改一处不会牵动全身。下面是我按这个项目场景整理的目录结构跟实际工程的划分方式基本一致cinderback/ ├── cinderback/ │ ├── __init__.py │ ├── cli.py # 子命令入口list / backup / status / cleanup │ ├── config.py # 加载 yaml环境变量覆盖参数校验 │ ├── client.py # 构建 openstacksdk Connection统一超时 │ ├── selector.py # 按标签、名字正则、状态筛卷 │ ├── backup.py # 快照 备份的发起逻辑 │ ├── poller.py # 状态轮询带超时和进度回调 │ ├── retention.py # 保留策略算哪些备份该删 │ ├── report.py # 汇总成表格或 JSON │ └── errors.py # 自定义异常便于 CLI 统一退出码 ├── conf/ │ └── cinderback.yaml # 默认配置样例 ├── tests/ │ ├── test_selector.py │ └── test_retention.py ├── requirements.txt ├── setup.cfg └── README.md这个切法的好处是selector和retention是纯函数不碰网络单元测试最容易写backup和poller依赖 Connection用 mock 或者一个本地 devstack 环境测cli只负责参数解析和输出格式薄薄一层。很多同类脚本把所有逻辑塞进main.py跑是能跑但第二次改需求就要重读一遍全部代码血泪经验。2.3 一次备份请求的完整走法把链路拉直看一遍后面读代码会顺很多。第一步CLI 解析参数加载cinderback.yaml把密码从环境变量里取出来注入配置。第二步client.py用这些凭证向 Keystone 换 token拿到服务目录构建出一个Connection对象。第三步selector.py调volumes()遍历卷列表按标签和名字过滤出候选卷。第四步对每个候选卷backup.py先调create_snapshot等快照 available再调create_backup。第五步poller.py每隔一段时间查一次备份状态直到终态。第六步report.py汇总retention.py清理。这里有个关键点真正的数据搬运发生在 cinder-backup 服务里脚本只是发起者。所以脚本能观测到的只有任务状态和 progress 字段看不到底层对象存储的写入速率。排查问题时这个认知很重要别在脚本里死等。3. 跑起来从鉴权到第一条备份成功的完整流程这一章按顺序走一遍目标是让你在一台能访问 Keystone 和 Cinder API 的机器上从零跑到第一条备份成功。假设你已经有可用的 OpenStack 环境也有一个具备volume和backup权限的账号。3.1 环境准备与依赖安装不要用系统自带的 Python 装依赖很容易和环境里的其它工具打架。建一个独立虚拟环境# 建目录拉代码建虚拟环境 git clone 你的仓库地址 cinderback cd cinderback python3 -m venv .venv source .venv/bin/activate # 安装依赖-U 保证拿到的是满足约束的最新版本 pip install -U pip pip install -r requirements.txt # 装成本地包这样命令行能直接调 cinderback pip install -e .pip install -e .这一步别省。它会把setup.cfg里声明的 console_scripts 入口装到虚拟环境的 bin 目录之后直接敲cinderback就行不用写python -m cinderback.cli。上线以后 cron 里调用的也是这个入口路径固定排错时好定位。依赖装完先别急着跑备份用cinderback --help看一眼子命令是否都注册上了。如果报ModuleNotFoundError八成是setup.cfg里的packages配少了或者虚拟环境没激活。3.2 配置文件哪些字段必须自己填conf/cinderback.yaml是唯一需要改的文件。密码不写进来走环境变量这是底线。# conf/cinderback.yaml auth: auth_url: https://keystone.example.internal:5000/v3 username: backup-bot password_env: CINDERBACK_PASSWORD # 只存变量名不存明文 project_name: ops user_domain_name: Default project_domain_name: Default region_name: RegionOne interface: internal # 走内网 endpoint省流量也更快 backup: max_workers: 4 # 同时发起备份的卷数量 poll_interval: 10 # 轮询间隔秒 poll_timeout: 7200 # 单个备份最长等 2 小时 name_prefix: cinderback- full_backup_cycle: 7 # 每 7 次里做一次全量 retention: keep_days: 14 min_keep: 3字段含义和改动建议字段含义改的时候注意什么interface用 public 还是 internal endpoint脚本跑在集群内网就用 internalmax_workers并发发起的备份数别超过 4后面避坑章会讲poll_timeout单个备份的等待上限卷大的时候要往上调full_backup_cycle全量与增量的比例决定恢复时需要保留多少链keep_days备份保留天数要和后端存储容量一起算密码注入用一条 export 就够# 生产环境建议从 secret 管理工具里取这里演示用环境变量 export CINDERBACK_PASSWORDyour-password3.3 三条命令跑通最小链路先看卷再备份再看结果。顺序别乱尤其第一次跑先确认筛出来的卷是你预期的那些。# 1. 列出会被选中的卷--dry-run 只打印不执行 cinderback list --tag backupdaily --pattern ^prod- # 2. 对筛出来的卷发起备份首次跑建议先 --limit 1 cinderback backup --tag backupdaily --incremental --limit 1 # 3. 查最近 24 小时的备份状态 cinderback status --since 24h第一条命令的输出大概长这样重点看 status 列in-use和available是能备份的detaching、restoring-backup这类状态先跳过VOLUME ID NAME SIZE STATUS BACKUP-ELIGIBLE a1b2c3d4-... prod-mysql-data 200G in-use yes e5f6a7b8-... prod-redis-01 100G in-use yes c9d0e1f2-... prod-tmp-scratch 50G detaching no第二条命令执行过程中会打印每个卷的阶段snapshotting → snapshot-available → backing-up → available。第一次跑建议只放一个卷过去等它完整走完确认后端存储那边真的落了数据再放开批量。第三条命令是给值班用的把结果直接贴进日报就行。3.4 用 cron 固定下来脚本能手动跑通只是第一步真正省事的是挂到 cron 上。# 周一到周六 02:10 跑增量周日 02:10 跑全量 10 2 * * 1-6 /opt/cinderback/.venv/bin/cinderback backup --tag backupdaily --incremental /var/log/cinderback.log 21 10 2 * * 7 /opt/cinderback/.venv/bin/cinderback backup --tag backupdaily --full /var/log/cinderback.log 21注意两件事。一是 cron 的环境变量是干净的CINDERBACK_PASSWORD不会自动继承要么在 crontab 里 export要么在包装脚本里 source 一个权限 600 的 env 文件。二是两层任务别挨太近全量那天耗时长增量任务要错开不然 cinder-backup 的队列会打满。提示第一次挂 cron 之后第二天一定要去翻日志确认任务真的跑了。cron 静默失败是最常见的“以为备份了其实没备份”。4. 核心代码拆解一个卷从选中到备份完成的完整链路这一章按执行顺序拆六段代码。每段都是项目里真实会出现的逻辑参数的取值和判断条件我会说明为什么这么写。4.1 加载配置把密码从文件里摘出去# cinderback/config.py import os import yaml class ConfigError(Exception): pass def load_config(path: str) - dict: with open(path, encodingutf-8) as f: cfg yaml.safe_load(f) or {} auth cfg.setdefault(auth, {}) # password_env 只是个变量名pop 掉避免它被继续往下传 pwd_env auth.pop(password_env, None) if pwd_env: password os.environ.get(pwd_env) if not password: raise ConfigError(f环境变量 {pwd_env} 未设置无法完成鉴权) auth[password] password # 必填项在这里一次校验完不要等跑到一半再抛 KeyError for key in (auth_url, username, project_name): if not auth.get(key): raise ConfigError(fauth.{key} 缺失) return cfg这段代码做了三件事读 yaml、把密码从环境变量注入、做必填校验。pop(password_env)的用意是防止后续代码把变量名当成密码用setdefault(auth, {})是为了配置文件里完全没写 auth 段时也能走到校验逻辑给出清晰报错而不是KeyError: auth。配置里的poll_timeout之类的数值字段建议在加载时顺手做一次类型转换和范围检查。字符串7200和整数7200在比较时不会报错但会在某天以“超时时间变成了 0”的形式翻车。4.2 构建 Connection鉴权和超时都要管# cinderback/client.py from openstack import connection def build_connection(cfg: dict, timeout: int 60): auth cfg[auth] conn connection.Connection( auth_urlauth[auth_url], usernameauth[username], passwordauth[password], project_nameauth[project_name], user_domain_nameauth.get(user_domain_name, Default), project_domain_nameauth.get(project_domain_name, Default), region_namecfg.get(region_name), interfacecfg.get(interface, internal), # 单请求超时防止某个 API 卡死把整个进程拖住 timeouttimeout, ) conn.authorize() return conn参数里有两个容易忽略的点。interface决定走公网还是内网 endpoint备份脚本跑在集群内走 internal 又快又省流量。timeout是单次 HTTP 请求的超时不是整个备份任务的超时后者由poller控制两个概念别混。conn.authorize()主动触发一次鉴权好处是凭证错误在启动阶段就暴露而不是等到第一个卷处理到一半才 401。如果环境是多域multi-domainuser_domain_name和project_domain_name必须显式写不能依赖默认值。同名用户在 Default 域和另一个域里同时存在时不写域信息会出现“密码明明对但就是登不上”的玄学现象。4.3 筛选卷状态白名单比黑名单稳# cinderback/selector.py import re def select_volumes(conn, name_patternNone, required_tagsNone, allowed_status(available, in-use)): regex re.compile(name_pattern) if name_pattern else None picked [] for vol in conn.block_storage.volumes(): if regex and not regex.search(vol.name or ): continue if required_tags: # Cinder 里标签存在 metadata 里key 是标签名 tags set((vol.metadata or {}).keys()) if not set(required_tags).issubset(tags): continue # 用白名单而不是黑名单新增的状态默认不处理更安全 if vol.status not in allowed_status: continue picked.append(vol) return picked这里最值得说的是最后那个判断。用白名单allowed_status而不是“排除 detaching、restoring 之类”原因是 Cinder 的卷状态会随版本增加黑名单永远列不全漏掉一个新状态就可能对一个正在做快照克隆的卷发起备份直接 409。白名单的代价是某些边缘状态会被跳过但备份这种场景少备一次可以补备坏了要恢复就麻烦了。标签过滤用issubset意思是“我要求的标签必须全都有”而不是“有任意一个就行”。日常用法是给卷打backupdaily、backupweekly这类标签然后按标签跑不同频率的任务。4.4 快照在前备份在后# cinderback/backup.py import time def wait_snapshot(conn, snapshot_id, timeout900, interval5): deadline time.monotonic() timeout while time.monotonic() deadline: snap conn.block_storage.get_snapshot(snapshot_id) if snap.status available: return snap if snap.status error: raise RuntimeError(f快照 {snapshot_id} 创建失败) time.sleep(interval) raise TimeoutError(f快照 {snapshot_id} 等待超时) def create_backup(conn, volume, name_prefixcinderback-, incrementalFalse): # 先打快照锁住一致性时间点直接从 in-use 卷备份会读到写过程中的中间态 snap conn.block_storage.create_snapshot( volume_idvolume.id, namef{name_prefix}snap-{volume.name}, forceTrue, ) wait_snapshot(conn, snap.id) backup conn.block_storage.create_backup( volume_idvolume.id, snapshot_idsnap.id, namef{name_prefix}{volume.name}-{int(time.time())}, is_incrementalincremental, forceTrue, ) return snap, backup为什么一定要先快照对in-use的卷文件系统还在持续写入直接从卷创建备份cinder-backup 读到的是某个不确定时刻的数据恢复出来可能是个不一致的文件系统。先打快照快照本身是秒级的一致性点就固定住了备份再从快照读代价是要多占一份快照空间。这是备份领域的老规矩很多脚本图省事跳过这步恢复的时候才知道后悔药没处买。forceTrue的作用是允许对in-use卷操作。不加这个参数Cinder 会直接拒绝。但 force 不是万能的卷处于detaching时加了也白加所以上一节的筛选是前置防线。4.5 轮询状态超时和进度都要有# cinderback/poller.py import time TERMINAL {available, error} def wait_backup(conn, backup_id, timeout7200, interval10, on_progressNone): start time.monotonic() while time.monotonic() - start timeout: b conn.block_storage.get_backup(backup_id) # progress 字段在不支持的后端上可能是 None别直接做算术 pct b.progress if b.progress is not None else ? if on_progress: on_progress(backup_id, b.status, pct) if b.status in TERMINAL: return b time.sleep(interval) raise TimeoutError(f备份 {backup_id} 超过 {timeout}s 未完成)轮询看着简单实际有两个坑。一是progress字段并非所有后端都填直接int(b.progress)会崩所以要判空。二是interval不能太小10 秒一次对 cinder-api 来说是正常的1 秒一次会让 API 的 QPS 统计很难看尤其是在并发 4 的情况下。on_progress这个回调参数是为了让 CLI 层决定怎么展示库层只负责把状态推出去。日志里我一般会记成backupid statuss progressp方便后面 grep。4.6 保留策略清理之前先保住链头# cinderback/retention.py import datetime def pick_expired(backups, keep_days14, min_keep3): now datetime.datetime.now(datetime.timezone.utc) cutoff now - datetime.timedelta(dayskeep_days) # 按创建时间倒序最新的在前 ordered sorted(backups, keylambda b: b.created_at, reverseTrue) expired [] for idx, b in enumerate(ordered): if idx min_keep: continue # 无论多老最近 min_keep 份不删 created datetime.datetime.fromisoformat( b.created_at.replace(Z, 00:00) ) if created cutoff: expired.append(b) return expiredmin_keep是兜底防止keep_days配错成 0 的时候把备份全删了。这个参数在真实事故里救过不少人。但光有这个还不够。增量备份是有链的删掉最早的全量后面的增量就恢复不了。所以实际清理逻辑里要先把备份按卷分组确保每个卷至少留一条is_incrementalFalse的全量再按时间删。这一步放在cleanup子命令里做删之前打印待删列表并要求确认或者加--yes才真删。5. 避坑与排查几个真会卡住你的地方下面五条是我在实际使用和改这类脚本时反复遇到的。每条按现象、原因、解决三段写遇到问题可以先对着现象对号入座。5.1 备份一直停在 creating日志里什么都没有现象cinderback backup跑了一晚上没退出早上去看控制台所有卷的备份都停在creating脚本日志里除了“等待中”没有任何有价值的信息。原因备份任务的实际执行方是 cinder-backup 服务脚本只是往队列里丢了任务。如果 backup 服务的 worker 数不够、队列积压或者后端对象存储写入慢任务就会长时间停在creating。API 层只返回一个任务记录看不出卡在哪。解决先cinder service-list看 backup 服务是否 up再翻 cinder-backup 的日志找Backup operation相关的行。确认瓶颈在服务端之后脚本这边把poll_interval调到 30、poll_timeout调到 4 小时同时把并发压到 2 以内。另外把 progress 打进日志至少能看出是不是在缓慢推进。5.2 对 in-use 卷报 409 Conflict现象筛选出来的卷明明是in-use发起备份时接口返回 409提示卷状态不可用。原因卷在筛选和发起之间状态发生了变化。虚拟机的热迁移、快照克隆、扩容都会让卷短暂进入detaching、extending之类的中转状态这个窗口期发起备份一定会被拒。解决两层面处理。业务层用状态白名单过滤只对available和in-use动手调用层给create_backup套一层带指数退避的重试遇到 409 等 30 秒再试最多三次。别用死循环重试中转状态卡住不动的卷要能被识别出来并报警而不是把整个任务挂死。5.3 换一个项目就 401密码明明是对的现象同一个脚本在 A 项目跑得好好的换到 B 项目的凭证后怎么都认证不过。原因多域环境下用户名不唯一。backup-bot在 Default 域里存在在另一个域里也有同名账号不显式指定域就会认错。还有一种情况是长任务用同一个 token而 token 默认一小时过期。解决配置里把user_domain_name和project_domain_name都写上不要依赖默认值。长任务在并发场景下每个 worker 用自己的 Connection或者干脆用 application credential 代替用户名密码有效期更可控。如果确实要用密码在任务开始前重新authorize()一次。5.4 并发调到 16 之后整个云的 API 都变慢现象为了赶在维护窗口内备完把max_workers从 4 调到 16结果备份没快多少其它业务的 Cinder 操作开始大量 503。原因每个 worker 都独立鉴权、独立拉卷列表、独立轮询16 个 worker 实际产生的请求量是单线程的十几倍其中大量是重复的元数据请求。cinder-api 的 worker 数是有限的被备份脚本占满之后正常业务就排不上队。解决并发回到 4 以内Connection 在进程内复用卷列表只拉一次然后分发。轮询阶段可以合并成一个循环统一查别每个 worker 各查各的。备份任务是 IO 密集但服务端串行的堆并发收益很低。5.5 恢复增量备份时报找不到父备份现象想恢复到三天前的状态选择某条增量备份报错说 base backup 不存在。原因cleanup子命令按keep_days删备份时把更早的全量删掉了而后面几条增量还依赖它。备份链断了恢复自然失败。解决清理逻辑改成按卷分组每组先找出所有is_incrementalFalse的全量保留最近一个全量及其之后的全部增量再对更早的数据做时间判断。如果嫌这套逻辑复杂保守做法是全量和增量用两套保留策略全量保留天数一定大于增量。改完拿测试环境的卷跑一遍“备份-清理-恢复”完整流程验证别只验证备份成功。6. 二次开发把这套脚本接进你自己的备份体系跑通之后再往下走通常有两个方向一是让备份结果能被监控系统消费二是让脚本本身变成可以复用的库。先说出报告这块。report.py现在大概率是打印表格二次开发时把它改成支持--format json输出一个稳定的结构# cinderback/report.py import json def render(results, fmttable): rows [ { volume_id: r.volume_id, volume_name: r.volume_name, backup_id: r.backup_id, status: r.status, size_gb: r.size_gb, duration_sec: r.duration_sec, } for r in results ] if fmt json: # 保持字段顺序稳定下游解析不会因为顺序变化误判 return json.dumps(rows, ensure_asciiFalse, indent2) return \n.join(f{r[volume_name]}\t{r[status]} for r in rows)这个结构定下来之后接监控就很简单了cron 跑完之后把 JSON 推到日志采集或者在结果里筛status ! available的行触发告警。我一般会给每类失败配一个退出码比如全成功 0、部分失败 2、全部失败 3这样上层的调度器不用解析正文就知道该不该重试。表格这一块把关键指标列出来做值班看板的时候直接引用指标含义建议阈值任务成功率available 数 / 总数低于 95% 告警单卷耗时从发起至终态超过 1 小时关注备份体积本次新增占用环比涨 30% 要查链深度每卷增量条数超过 7 条强制做全量另一个方向是把backup.py里的create_backup抽成独立函数命令行只做参数解析和调用。抽出来之后它可以被其它系统直接 import比如你们的发布平台在做数据库升级前自动打一个备份调一次函数就行不用去拼 shell。这时候要顺手把日志改成logging.getLogger(__name__)的方式别用 print不然被调用方没法控制输出。还有一个我强烈建议加的改造dry-run 覆盖到清理环节。备份的 dry-run 只是打印删备份的 dry-run 才是真正能救命的。cleanup默认只打印待删列表加--yes才真删这个设计上线前一定要有。从那以后我每次改保留策略都会先在测试环境跑一遍“备份 → 清理 → 恢复”确认恢复出来的卷能挂载、文件系统能通过检查再动生产。备份脚本最容易给人一种“我写了备份所以我有备份”的错觉真正验证过的只有恢复那一步。这套脚本本身不复杂价值在于把固定动作变成可重复、可观测的流程希望帮到你。本文还有配套的精品资源点击获取