OpenStack Cinder备份助手cinderback:从快照到恢复的生产级实践
简介面向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_urlconf[auth_url], usernameconf[username], passwordconf[password], project_nameconf[project_name], user_domain_nameconf.get(user_domain_name, Default), project_domain_nameconf.get(project_domain_name, Default) ) sess session.Session(authauth, timeout30) return Client(3, sessionsess)这里的关键在于session.Session(authauth, timeout30)这一行。keystoneauth1的 Session 会在收到 401 响应时自动用原始 auth 插件重新申请 token然后重放请求。timeout30是给底层 HTTP 请求设置的超时时间避免某个网络异常让你挂死在整个任务中间。Client(3, sessionsess)里的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(fVolume {volume_id} status is {volume.status}, expected available) if self._check_quota(volume.size): raise RuntimeError(fQuota exceeded for volume {volume_id}) # 阶段一创建一致性快照 snap self.cinder.volume_snapshots.create( volume_idvolume_id, namef{backup_name}-snap, forceTrue ) self._wait_for_status(snap.id, snapshot, available) # 阶段二上传到 Swift通过 Cinder backup-create backup self.cinder.backups.create( volume_idvolume_id, namebackup_name, containercinderback ) self._wait_for_status(backup.id, backup, available) # 阶段三校验元数据 if not self._verify_backup(backup, volume): raise BackupVerificationError(fBackup {backup.id} verification failed) return backup.id这一段代码是整个脚本的灵魂。_wait_for_status是一个轮询方法每 10 秒查询一次资源状态直到目标状态出现或者超时默认 30 分钟。这里有个设计细节创建快照时用了forceTrue意思是即使卷处于in-use被虚拟机挂载状态也强制创建快照。这在 Cinder 里默认是允许的但底层如果是 LVM 驱动强烈建议提前确认有没有lvm_volume_clear配置否则可能造成数据不一致。关键参数说明containercinderback是对象存储里的容器名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增加incrementalTrue参数让 Cinder 自己判断差异数据注意这个参数在 Cinder API v3 里是支持的但后端驱动必须支持backup_use_temp_snapshot和增量备份特性。增量备份的调用代码def create_incremental_backup(self, volume_id, base_backup_idNone): kwargs { volume_id: volume_id, name: fincr-{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(fSize mismatch: backup {backup.size} ! volume {volume.size}) return False # 核对对象存储里的数据块 objects self.swift_container.list(containercinderback) 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(fData incomplete: {total_bytes} bytes found) return False return True逻辑说明backup.size是 Cinder 记录的卷容量单位 GBvolume.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(daysretention_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.pydef restore_backup(self, backup_id, new_volume_name, volume_typeNone): backup self.cinder.backups.get(backup_id) az self.conf[restore][availability_zone] vol self.cinder.volumes.create( namenew_volume_name, sizebackup.size, volume_typevolume_type or self.conf[restore][default_volume_type], availability_zoneaz ) self._wait_for_status(vol.id, volume, available) restore self.cinder.restore.create( backup_idbackup_id, volume_idvol.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(fTemporary failure: {e}, will retry) raise except FatalError as e: logger.error(fFatal 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 管理并加上Restartalways。5. 项目跑通与高频踩坑记录五个真实教训5.1 部署与初始化从拉代码到第一步备份成功这个项目不挑环境操作系统只要能装 Python 3.8 以上就能跑。依赖安装很简单就一个文件pip install -r requirements.txtrequirements.txt里的核心依赖是python-cinderclient、keystoneauth1、python-swiftclient、pyyaml、schedule这五个包。装完后第一步是先执行一次不带任何参数的脚本检查配置是否能连上 OpenStackpython 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创建快照时加forceFalse强制卷必须处于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(prefixbackup_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, jsonpayload, timeout5) 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才把这个隐患堵上。从那以后我凡是加任何“通知/告警”扩展都要强制走一遍“人为制造一次失败来验证告警能通”的流程这已经成习惯了。希望这些经验能帮你把这个脚本改造成真正适合自己环境的样子尽早把备份这件事从“玄学”变成“日常”。本文还有配套的精品资源点击获取