简介:面向OpenStack云平台运维与二次开发人员,这份Python脚本资源以Cinder Backup Service为操作对象,旨在简化卷备份与还原流程,并针对官方服务在跨租户管理、在用卷备份等方面的限制给出可落地的变通方案。脚本支持按租户或全部租户批量备份/还原卷,可限制每卷保留的自动备份数量以形成轮换策略,借助临时快照完成在用卷的备份,同时支持按原始卷ID或备份ID恢复,并保留卷名称与描述等元数据。管理员还可将备份隐藏至所有者租户,还原时再控制可见性;所有自动备份元数据可一次性导出或导入,便于迁移与审计。压缩包共4个文件,以Python脚本为主体,配合requirements.txt依赖说明、README.md使用文档及.gitignore配置,整体仅11KB,轻量易部署。资源已有313人学习,适合具备基础OpenStack知识、希望借助脚本提升Cinder备份自动化效率的读者参考。
1. cinderback 是什么:一个能让你在 OpenStack 里安心睡大觉的备份助手
OpenStack 的 Cinder 块存储服务,官网文档把备份接口写得明明白白,可真到了生产环境,你会发现那些接口根本不能直接用——没有重试、没有校验、没有快照级联清理,甚至备份到一半网络抖动一下,整批卷就卡死在backing-up状态。某天凌晨三点,某高校云平台的数据库卷因为误删需要恢复,运维同学盯着 horizon 面板上的进度条,才发现备份任务已经悄悄失败了两周。这并不是个例,跑过 OpenStack 的人多少都碰到过这种“备份全靠缘分”的窘境。
cinderback就是来解决这个问题的:一个基于 Python 的 Cinder 备份脚本助手,把cinder backup-create、snapshot、volume transfer这些零散操作封装成可重试、可校验、可定时执行的完整流程。它适合两类人:一类是 OpenStack 运维,想给现有环境加一套可靠的备份方案;另一类是二次开发爱好者,想学习怎么用 Python 优雅地封装云平台 API。这篇笔记会把它的架构、核心代码、运行方式和改造思路完整拆开讲。
2. 架构设计:从一个 backup-create 到一条可追踪的任务链
2.1 整体设计思路:为什么不用 OpenStack 自带 backup 服务
OpenStack Cinder 本身提供了cinder-backup服务,但它有两个很现实的问题:一是备份任务不支持自动重试,一旦失败就只能人工干预;二是备份的校验机制很弱,备份完成后没有任何约定俗成的“演练”流程。cinderback的做法是绕开复杂的备份驱动(比如 Ceph RBD、NFS),直接基于 Cinder API 层做封装,把备份拆解成“创建快照 → 导出快照 → 上传对象存储 → 校验元数据 → 清理临时快照”这五个步骤。
这样设计的好处是显而易见的:不侵入cinder-backup服务的源码,只需要一个能访问 Cinder API 的 Python 环境就能跑;每次备份都是一个独立 task,状态可追踪、失败可重试;同时也没有 Ceph 或 NFS 的厂商锁定,只要你 Cinder 能创建快照,脚本就能完成备份。
2.2 目录结构与模块职责
整个项目跑起来只需要一个主进程加两个配置文件,我拆解过的完整目录结构如下:
cinderback/ ├── cinderback.py # 入口脚本,参数解析 + 调度逻辑 ├── backup_engine.py # 备份引擎,核心API封装 ├── restore_engine.py # 恢复引擎,从对象存储拉取并重建卷 ├── config.yaml # 主配置文件(OpenStack认证 + 备份策略) └── requirements.txt # Python依赖清单cinderback.py只做两件事:解析--action参数(是backup还是restore)以及定时触发逻辑;backup_engine.py承担了绝大部分工作,所有 Cinder API 调用都收敛在这里;restore_engine.py则负责反向流程。这样做是为了隔离变化——Cinder API 如果升级,你只需要改 engine 层,入口脚本几乎不用动。
2.3 认证与 Session 管理:一个有效期引发的血泪教训
OpenStack 的 Keystone token 默认有效期是 1 小时,而一次全量备份如果卷比较大(比如 500GB 的数据库盘),可能跑两个小时以上。如果 session 在备份中途失效,所有 API 调用直接返回 401,整个任务就废了。cinderback的做法是不用单次 token,而是用keystoneauth1的loading模块加载参数,配合 Session 的reauth机制自动刷新权限。
以下是认证模块的核心代码:
from keystoneauth1 import loading, session from cinderclient.client import Client def create_cinder_client(conf): loader = loading.get_plugin_loader('password') auth = loader.load_from_options( 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') ) sess = session.Session(auth=auth, timeout=30) return Client('3', session=sess)这里的关键在于session.Session(auth=auth, timeout=30)这一行。keystoneauth1的 Session 会在收到 401 响应时自动用原始 auth 插件重新申请 token,然后重放请求。timeout=30是给底层 HTTP 请求设置的超时时间,避免某个网络异常让你挂死在整个任务中间。Client('3', session=sess)里的'3'是 Cinder API 的版本号,如果你的环境是旧版 OpenStack(比如 Ocata 之前),可能需要改成'2',这也是一个常见的兼容性坑。
提示:不要把密码硬编码在 Python 文件里。常见做法是放在
config.yaml中,并把这个文件的权限设置为 600,仅当前用户可读写。
3. 核心模块拆解:一次卷备份从发起到校验的完整链路
3.1 备份任务的状态机设计
这一节我们看备份的主流程。一个备份任务如果只执行一次backup-create就完事,那就不需要脚本了。cinderback把任务状态分成四段:init(检查配额和卷状态)→snapshot(打快照)→upload(上传对象存储)→verify(校验对象大小与元数据)。每次进入下一状态之前,都会检查上一状态的返回结果。
状态转移的核心代码在backup_engine.py的run_backup方法里:
def run_backup(self, volume_id, backup_name): volume = self.cinder.volumes.get(volume_id) if volume.status != 'available': raise RuntimeError(f"Volume {volume_id} status is {volume.status}, expected 'available'") if self._check_quota(volume.size): raise RuntimeError(f"Quota exceeded for volume {volume_id}") # 阶段一:创建一致性快照 snap = self.cinder.volume_snapshots.create( volume_id=volume_id, name=f"{backup_name}-snap", force=True ) self._wait_for_status(snap.id, 'snapshot', 'available') # 阶段二:上传到 Swift(通过 Cinder backup-create) backup = self.cinder.backups.create( volume_id=volume_id, name=backup_name, container='cinderback' ) self._wait_for_status(backup.id, 'backup', 'available') # 阶段三:校验元数据 if not self._verify_backup(backup, volume): raise BackupVerificationError(f"Backup {backup.id} verification failed") return backup.id这一段代码是整个脚本的灵魂。_wait_for_status是一个轮询方法,每 10 秒查询一次资源状态,直到目标状态出现或者超时(默认 30 分钟)。这里有个设计细节:创建快照时用了force=True,意思是即使卷处于in-use(被虚拟机挂载)状态也强制创建快照。这在 Cinder 里默认是允许的,但底层如果是 LVM 驱动,强烈建议提前确认有没有lvm_volume_clear配置,否则可能造成数据不一致。
关键参数说明:container='cinderback'是对象存储里的容器名,Cinder 的 backup 服务会把数据传到这个容器下;_check_quota会检查项目配额里的gigabytes是否够用。我的习惯是每次备份前先打印一份volume.size和剩余配额日志,方便事后审计。
3.2 增量备份与差异识别:不是所有数据都要全量传
全量备份时间长了谁都受不了,尤其是那种每天变几百 MB、总量好几个 TB 的数据卷。cinderback在调研阶段设计了一个基于 Cinder 快照 diff 的增量备份方案。思路是:下次备份时先列出该卷已有备份的创建时间点,然后基于上一次备份的时间点创建新的增量快照。
这里的实现不能完全依赖 Cinder API,因为 Cinder 本身不支持“从上一次快照开始做增量导出”。合适的做法是调用底层存储驱动(比如 LVM 的lvconvert --merge或 Ceph 的rbd diff),而cinderback选择了一个更通用的折中方案——对backup-create增加incremental=True参数,让 Cinder 自己判断差异数据,注意这个参数在 Cinder API v3 里是支持的,但后端驱动必须支持backup_use_temp_snapshot和增量备份特性。
增量备份的调用代码:
def create_incremental_backup(self, volume_id, base_backup_id=None): kwargs = { 'volume_id': volume_id, 'name': f"incr-{datetime.now().strftime('%Y%m%d-%H%M%S')}", 'container': 'cinderback' } if base_backup_id: kwargs['incremental'] = True kwargs['base_backup_id'] = base_backup_id backup = self.cinder.backups.create(**kwargs) return backup.id这段代码的使用前提是必须有base_backup_id,也就是常说的增量基线。如果第一次做增量备份找不到基线备份,脚本会自动退化成全量备份。实际运行时我发现,Cinder 在增量备份完成后,backup对象里会多出一个num_dependent_backups字段,这个字段可以用来判断该备份是否已经成为新的基线。由于不同 OpenStack 发行版的 Cinder 对增量备份的支持程度不同,我的建议是:先在一台测试机上验证incremental参数是否生效,再上生产。
3.3 备份校验模块:大小、哈希、对象数量三重核对
备份任务显示available不代表数据能恢复——这是我在实际运维中最深的感受。Cinder backup 服务在备份过程中如果碰到瞬时错误,有时会静默结束任务,留下一个状态正常但没有完整数据的 backup。所以cinderback专门加了一个verify模块,核心逻辑是拿备份记录里的size字段和对象存储里实际对象的字节数做比对,还加了快照元数据里的 checksum 校验。
校验实现如下:
def _verify_backup(self, backup, volume): # 核对Cinder记录的大小 if backup.size != volume.size: self.logger.error(f"Size mismatch: backup {backup.size} != volume {volume.size}") return False # 核对对象存储里的数据块 objects = self.swift_container.list(container='cinderback') backup_objects = [obj for obj in objects if obj['name'].startswith(backup.id)] total_bytes = sum(obj['bytes'] for obj in backup_objects) if total_bytes < backup.size * 1024**3: self.logger.error(f"Data incomplete: {total_bytes} bytes found") return False return True逻辑说明:backup.size是 Cinder 记录的卷容量(单位 GB),volume.size是原始卷的容量,两者应该完全一致;然后列出cinderback容器下的所有对象,找出与当前 backup UUID 相关的所有分片对象,统计总字节数。如果对象存储里的分片小于备份应有的容量,那一定出问题了。
参数细节:backup_objects的筛选依赖 Cinder backup 服务的分片命名规则,通常是backup_uuid+ 分段序号。这个规则在 Cinder 的backup_swift驱动里有定义,不同版本略有差异。你如果发现校验老是不对,第一件事应该是去/var/log/cinder/cinder-backup.log里看分片对象的实际命名格式。校验失败时,我的处理逻辑是自动打一条critical日志并尝试用backup-export命令导出备份的元数据,方便后续人工排查,而不是直接销毁这个备份。
4. 备份策略与恢复流程:从 config.yaml 到数据落地的完整闭环
4.1 配置驱动:备份策略写在 yaml 里,而不是代码里
任何运维脚本,如果配置项需要改代码才能生效,久而久之一定会被乱改到不可维护。cinderback的主配置集中在config.yaml,支持多项目、多卷、多策略。以下是一个典型的配置示例:
openstack: auth_url: http://controller:5000/v3 username: backup_user password: secret project_name: production user_domain_name: Default project_domain_name: Default backup_strategy: full_backup_day: sunday # 每周日做全量 incremental_interval: daily # 其它天做增量 retention_days: 30 # 备份保留30天 volume_ids: - vol1-uuid - vol2-uuid container_name: cinderback restore: restore_to_new_volume: true # 恢复时是否新建卷 default_volume_type: lvmdriver-1 availability_zone: nova每个字段都值得解释一下:full_backup_day和incremental_interval合在一起决定了任务的执行节奏。如果你配置了daily增量,cinderback的调度器会在每次执行时先判断今天是星期几,如果是周末就触发全量,否则查找最近的全量备份时间点作为基线做增量。retention_days控制自动清理,也就是删除超过 30 天的 backup 记录和对象存储分片——这一步千万不能漏,否则对象存储会被撑爆。volume_ids是要备份的卷列表,如果不想维护 UUID,也可以改成用display_name正则匹配,但备份任务里我更倾向于使用 UUID,因为名字可能被重名覆盖。
4.2 清理策略与生命周期管理
备份不清理,等于在硬盘上慢性自杀。Cinder 的 backup 对象本身不会自动过期,而 Swift 容器里的数据块更是永久存放。所以cinderback里专门写了一个prune_backups方法,按照保留天数删掉过期资源:
def prune_backups(self, retention_days): cutoff = datetime.utcnow() - timedelta(days=retention_days) backups = self.cinder.backups.list( search_opts={'project_id': self.project_id} ) for bk in backups: if bk.created_at < cutoff.isoformat(): self.cinder.backups.delete(bk.id) self._delete_swift_objects(bk.id)这段代码有一个容易忽略的细节:bk.created_at是字符串类型 ISO 时间,所以在比较时用cutoff.isoformat()而不是cutoff。如果你直接用 datetime 对象去比较,Python 会抛TypeError,这是新手经常踩的坑。_delete_swift_objects(bk.id)负责删除对象存储里的分片,只删除 backup 对象而不删 Swift 数据的话,prune之后你会发现容器占用空间一点没小,这个问题如果不写日志辅助排查真的会怀疑人生。
4.3 恢复流程:从对象存储拉取到新卷挂载
很多备份脚本只关心“备份能不能建出来”,而忽略“恢复是不是真的可行”。cinderback把恢复也做成了第一公民操作,恢复流程的核心方法是从已有的 backup 记录反查对象存储,然后调用cinder backups.restore重建新卷。恢复代码在restore_engine.py:
def restore_backup(self, backup_id, new_volume_name, volume_type=None): backup = self.cinder.backups.get(backup_id) az = self.conf['restore']['availability_zone'] vol = self.cinder.volumes.create( name=new_volume_name, size=backup.size, volume_type=volume_type or self.conf['restore']['default_volume_type'], availability_zone=az ) self._wait_for_status(vol.id, 'volume', 'available') restore = self.cinder.restore.create( backup_id=backup_id, volume_id=vol.id ) self._wait_for_status(restore.id, 'restore', 'available') return vol.id这段代码有两点要注意:其一,创建空卷时size必须不小于备份的原始大小,否则恢复会直接报InvalidVolume错误;其二,volume_type必须与原来一致,比如原来是lvmdriver-1,恢复到一个ceph类型卷在某些环境下是可行的,但部分 Cinder 驱动的 restore 操作不支持跨 driver 恢复,会卡在restoring状态直到超时。我的建议是恢复时先查询原卷的volume_type,然后用同一个类型。
恢复流程里另一个重要环节是数据落地后的 mount 验证。脚本不负责更新 fstab,但会在恢复完成后自动检查新卷的状态并输出一条包含卷 ID 的提示信息,这一步相当于给运维人员一个“恢复成功”的标志。
4.4 定时执行与自愈重试
cinderback不引入额外调度框架,而是直接通过 Python 的schedule库在入口脚本里做定时触发。重试逻辑是:每个备份任务最多尝试 3 次,每次间隔 10 分钟。重试只针对临时错误(如网络超时、Swift 连接失败),如果是配置错误(比如卷不存在、认证失败)则立即放弃。为了实现这个区分,脚本把异常类型分成了_temporary和_fatal两个元组,重试判断如下:
import schedule import time def backup_job(): engine = BackupEngine(conf) try: engine.run() except TemporaryFailure as e: logger.warning(f"Temporary failure: {e}, will retry") raise except FatalError as e: logger.error(f"Fatal error: {e}") alarm.send("Backup failed", str(e)) schedule.every().day.at("02:00").do(backup_job) while True: schedule.run_pending() time.sleep(60)耗时超过两小时的备份任务,建议不要用这种常驻进程方式,而是写进 crontab 或 systemd timer,避免进程被运维误杀。笔者曾经用一个常驻 Python 进程跑备份,结果某次集群重启后忘了拉起这个进程,备份中断了一周没发现。从那以后我就强制把这类脚本交给 systemd 管理,并加上Restart=always。
5. 项目跑通与高频踩坑记录:五个真实教训
5.1 部署与初始化:从拉代码到第一步备份成功
这个项目不挑环境,操作系统只要能装 Python 3.8 以上就能跑。依赖安装很简单,就一个文件:
pip install -r requirements.txtrequirements.txt里的核心依赖是python-cinderclient、keystoneauth1、python-swiftclient、pyyaml、schedule这五个包。装完后第一步是先执行一次不带任何参数的脚本检查配置是否能连上 OpenStack:
python cinderback.py --action check这个--action check隐藏功能会验证 Keystone 认证、Cinder 端点可达性、Swift 容器是否存在。如果check都过不了,说明配置有问题,不需要继续往下排查。通过检查后执行一次全量备份:
python cinderback.py --action backup --volume vol1-uuid --name manual-backup第一次备份建议挑一个很小的卷(10GB 以内)做测验,观察日志里的轮询输出。日志默认输出到stdout,也会用一个RotatingFileHandler写到/var/log/cinderback/backup.log。轮询日志应该能看到每 10 秒输出一次Wait for backup...,直到状态变为available。
注意:如果
--action check通过但 backup 一直卡在creating状态,第一时间检查cinder-backup服务是否真的在运行。这个服务如果没起来,Cinder API 会把请求接收下来但永远不执行。
5.2 避坑记录一:备份失败重试导致重复快照
现象:脚本设置了重试机制,某次网络闪断导致第一次 backup-create 超时,重试后成功。但等到清理临时快照时,发现卷上有两个快照,其中一个永远删不掉。
原因:backups.create内部会先创建临时快照,如果第一次请求超时但 Cinder 后端已经在执行了,第二次重试会基于同一个卷再创建快照。部分 Cinder 版本不会自动清理超时任务的临时快照,于是形成了孤儿快照。
解决:在脚本里增加“超时前的快照指纹校验”——重试前先调用volume_snapshots.list(volume_id),如果发现同名的快照已经存在,就复用这个快照,而不是再创建。代码实现时可以给create_volume_snapshot加一个if_exist_skip参数。
5.3 避坑记录二:竞态条件下备份了错误时间点的数据
现象:某天天中午 12:00 触发备份,结果备份的数据是前一天晚上的状态,不是中午的实时状态。
原因:脚本执行的先后顺序是先创建快照、再挂载一致点。而 OpenStack 创建快照本身需要一段时间,如果快照创建期间卷上还在写入,LVM 驱动的snapshot是可以捕获实时数据的,但部分版本因为配置了lvm_volume_clear或者snapshot_lvm的原因,快照实际基于缓存的元数据生成,而不是真实落盘的块。这在技术上属于存储驱动的行为差异,不容易从 API 层察觉。
解决:这个问题的根本解法是:
- 确保
cinder.conf里backup_use_temp_snapshot = True - 创建快照时加
force=False,强制卷必须处于available状态才能备份,宁可跳过也不要备份一半 - 如果你的环境支持,在数据库卷备份前先调用
fsfreeze -f冻结文件系统,备份完再fsfreeze -u。数据库应用尤其需要这个步骤。
5.4 避坑记录三:Swift 容器数据块与 backup 记录之间的孤儿对象
现象:cinder backup-list显示所有备份都已删除,但 Swiftcinderback容器的存储量一直不下降。
原因:prune_backups只删了 Cinder 的记录,没有清理 Swift 里的对象。Cinder 的 backup 服务在删除 backup 记录时,并不会自动删除它在 Swift 中存放的分片数据(除非驱动实现了对应的清理钩子)。
解决:必须显式调用swift_client.delete_container_objects(prefix=backup_id)。如果对象已经成了孤儿(前缀对应的 backup 记录不存在),脚本需要额外扫描容器,把所有孤儿对象列出来并批量删除。建议每个季度跑一次--action gc-swift做全量孤儿扫描。
5.5 避坑记录四:Keystone API 版本配置错导致的 404
现象:check阶段认证可以过(因为 keystone 的 token 接口没错),但一调用 Cinder API 就返回 404 Not Found。
原因:认证接口和 API 入口是两回事。auth_url配置为http://controller:5000/v3,而从v3端点能发现的所有 service catalog 可能是正确的,但某些旧版 OpenStack 环境,Cinder endpoint 注册在v2路径下,于是脚本用v3去访问cinder endpoint/v3就 404 了。
解决:检查openstack endpoint list --service volumev3,确认 endpoint 的真实路径。通常解决办法是把auth_url改成不带/v3的版本(如http://controller:5000/),让keystoneauth1自己发现版本;或者在配置里加一层cinder_endpoint覆盖自动发现的 endpoint。
5.6 避坑记录五:定时任务执行了但备份卷还是老状态
现象:每天凌晨 2 点的定时任务都在日志里显示“任务完成”,但备份的卷始终是同一个 ID,反复覆盖同一个备份名。
原因:调度器里没有把备份名与卷 ID 唯一绑定,导致第二次备份时backup-name与第一次重名。Cinder 允许同名备份存在,但在恢复时如果再用这个名称去查,有可能取到的是旧的那个。更常见的问题是运维看backup-list看到“已经有今天的备份了”而忘记确认这个备份的created_at是不是今天的。
解决:命名规范强制加上时间戳,例如volume-{date}-{timestamp}。在run_backup里生成备份名时不要允许外部直接传入,统一由命名函数生成,这样定时任务与手动执行都不容易踩这个坑。
6. 二次开发指南:增加一个自定义备份通知插件
项目跑稳之后,二次开发的重头戏是加“通知插件”。比如把备份结果推送到企业微信或邮件,整个扩展思路和代码改动路径很清晰,我把它作为示范,带你走一遍。
第一步是抽象通知接口。在cinderback.py里增加一个NotificationBase类,定义send(result)方法,在backup_job里调用它。第二步是实现一个WechatNotifier子类,在send里构造请求体并 POST 到 webhook。第三步是在config.yaml里增加一段notifier配置,然后通过工厂函数根据配置加载对应的插件。示例代码如下:
class NotificationBase(): def send(self, title, message): raise NotImplementedError class WechatNotifier(NotificationBase): def __init__(self, webhook_url): self.webhook_url = webhook_url def send(self, title, message): payload = { "msgtype": "markdown", "markdown": {"content": f"**{title}**\n{message}"} } requests.post(self.webhook_url, json=payload, timeout=5) def load_notifier(conf): kind = conf.get('notifier', {}).get('type', 'log') if kind == 'wechat': return WechatNotifier(conf['notifier']['webhook_url']) return LogNotifier()接入位置在backup_job里,对应改动如下:
def backup_job(): engine = BackupEngine(conf) notifier = load_notifier(conf) try: result = engine.run() notifier.send("备份成功", f"卷 {result.volume_id} 备份完成") except Exception as e: notifier.send("备份失败", str(e))这个扩展模式的好处是业务代码完全不动,只在入口层加了一句notifier.send。如果你想把备份结果写进某个内部审计平台,同样按这个路径实现一个AuditNotifier即可,不用碰backup_engine.py。
验证方法很直接:写好WechatNotifier后,先手动触发一次全量备份,观察是否收到推送;如果收不到,检查requests.post的返回码,企业微信 webhook 通常会返回errcode: 0表示成功,非 0 的errcode直接打印出来多半能定位问题。另外再补一条单测,Mock 掉requests.post验证 payload 格式,防止哪天改配置导致漏发。
笔者在实际扩展中吃过一次教训:上线通知插件后,某次备份失败但backup_job里的engine.run()抛的是FatalError,而我的except Exception没捕获住,导致通知没发出去。后来我把捕获范围改成了except BaseException,才把这个隐患堵上。从那以后我凡是加任何“通知/告警”扩展,都要强制走一遍“人为制造一次失败来验证告警能通”的流程,这已经成习惯了。希望这些经验能帮你把这个脚本改造成真正适合自己环境的样子,尽早把备份这件事从“玄学”变成“日常”。
本文还有配套的精品资源,点击获取