ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Cinder备份绕过API直连DB+RPC的运维实践

Cinder备份绕过API直连DB+RPC的运维实践

简介:这是一份面向OpenStack运维工程师与云平台开发者的Python工具脚本,专为解决Cinder卷备份服务在多租户场景下的功能局限而设计,如跨租户备份可见性控制、自动备份轮换、运行中卷的无中断备份等实际运维痛点。资源包共4个文件,含核心脚本cinderback.py(实现全量/单卷备份还原、元数据导出导入、快照临时卷机制)、README.md(使用说明与权限配置)、requirements.txt(依赖声明)及.gitignore(开发规范),整体仅11KB,轻量易部署。目前已有313人学习下载,适合中高级云平台从业者快速集成、二次开发或作为Cinder Backup Service增强方案参考。读者可直接复用其多租户凭据管理逻辑、备份生命周期控制策略及兼容旧版cinderclient的降级处理机制,显著提升OpenStack块存储灾备流程的自动化与健壮性。

1. Cinder备份不是“一键导出”,而是要绕过OpenStack API黑匣子的精准手术

某高校实验室部署了一套OpenStack私有云,运行着200+台虚拟机,核心业务数据全存在Cinder卷里。某天存储后端突发故障,管理员紧急执行cinder backup-create,结果发现:备份任务卡在creating状态超4小时、备份列表里查不到记录、日志里只有一行No handler found for backup——连报错都像谜语。这不是个例。Cinder原生备份接口依赖后端驱动强耦合,RBD、LVM、NetApp等不同存储类型返回的字段、状态码、重试逻辑完全不同;更麻烦的是,OpenStack服务间调用链太长(Nova→Cinder→Backup→Driver),任意一环超时或认证失效,整个流程就静默失败。cinderback就是为这种场景而生:它不走OpenStack REST API代理层,而是直连Cinder数据库读取卷元数据、调用备份服务内部RPC方法、按存储类型自动切换序列化策略,把一次“可能失败的API调用”变成“可追踪、可重试、可审计的本地脚本”。适合正在维护生产级OpenStack环境、需要做定期快照归档、灾备演练或跨集群迁移卷数据的运维和平台开发人员——尤其当你已经翻过三次/var/log/cinder/cinder-backup.log却找不到backup_id生成痕迹时,这个脚本就是你的后悔药。


2. 架构设计:为什么放弃REST API而选择数据库+RPC双通道

cinderback的核心设计决策不是炫技,而是对OpenStack生产环境血泪经验的妥协。我们先看它绕开了什么:

  • 不依赖python-cinderclient:该库封装了所有API调用,但错误处理是“统一抛ClientException”,丢失了底层驱动返回的error_code=1024这类关键诊断信息;
  • 不信任cinder-backup服务的HTTP健康检查:curl -I http://backup:8776/healthz返回200不代表备份队列没积压,更不代表RBD pool配额没耗尽;
  • 拒绝硬编码存储后端类型:同一套代码要跑在LVM(本地磁盘)、Ceph RBD(块设备)、Pure Storage(SAN)三种环境,每种的备份触发方式、元数据校验逻辑、清理策略完全不同。

它选择了两条更“脏”但更可控的路径:直连MySQL读取volumes和backups表状态+通过oslo_messaging直接调用cinder.backup.manager.BackupManager的RPC方法。这种设计让脚本具备三个硬能力:

  1. 状态预检:在发起备份前,先查volumes.status='available' AND volumes.deleted=0,过滤掉被挂起或软删除的卷;
  2. 失败回溯:若备份失败,直接查backups表的fail_reason字段(如rbd: image not found),比解析cinder-backup.log快10倍;
  3. 原子重试:对backup-create操作,脚本会先插入一条backups记录(status='creating'),再调用RPC;若RPC超时,下次执行时检测到该记录存在且状态非available/error,自动跳过并打印[SKIP] backup_id=xxx already in progress。

提示:这种设计要求脚本运行节点必须能访问Cinder数据库(通常需配置mysql -h cinder-db -u cinder -p权限)和RabbitMQ(用于RPC通信)。这不是缺陷,而是把“环境依赖”显性化——你永远知道问题出在DB连不上,而不是在某个隐藏的API网关超时。

2.1 目录结构与模块职责划分

项目解压后是极简的单目录结构,没有setup.py或pyproject.toml,因为它的定位是“可拷即用”的运维工具,而非PyPI包:

cinderback/ ├── cinderback.py # 主入口:参数解析 + 状态路由 + 执行分发 ├── config/ # 配置文件目录(非必需,可删) │ ├── cinder.conf # Cinder服务配置副本(用于提取db_url/rabbit_url) │ └── backup_policy.yaml # 备份策略:保留份数、压缩开关、存储后端映射 ├── drivers/ # 存储后端适配器(核心差异点) │ ├── lvm.py # LVM卷:使用dd+gzip直写/dev/vg/lv │ ├── rbd.py # RBD卷:调用rbd export-diff生成增量包 │ └── pure.py # Pure Storage:调用purestorage.PureHTTPClient快照导出 ├── utils/ # 通用工具 │ ├── db.py # 封装SQLAlchemy连接池 + 常用查询(如get_volume_by_id) │ ├── rpc_client.py # oslo_messaging RPC客户端工厂(支持rabbit/amqp) │ └── logger.py # 结构化日志(输出到console + /var/log/cinderback/) └── tests/ # 单元测试(仅覆盖drivers层,mock DB/RPC) └── test_rbd.py

注意:drivers/目录是项目可扩展性的关键。新增一种存储类型(比如netapp.py),只需实现create_backup()和cleanup_backup()两个方法,无需改动主流程。这种设计让cinderback天然适配私有云定制化需求——某公司曾在此基础上增加huawei.py,对接其OceanStor存储的快照导出API,三天内完成上线。

2.2 核心流程:从命令行到备份完成的7个关键步骤

以最常用命令为例:

python cinderback.py --volume-id 5a3b9c1d-2e4f-5a6b-8c9d-0e1f2a3b4c5d --policy daily

执行过程被拆解为7个原子步骤,每步失败都会中断并返回明确错误码:

  1. 参数校验:检查--volume-id是否为合法UUID格式,--policy是否在config/backup_policy.yaml中定义;
  2. DB预检:连接MySQL,执行SELECT id,status,host FROM volumes WHERE id=%s,确认卷存在且status='available';
  3. 策略加载:读取backup_policy.yaml,获取daily策略的retention_days: 7、compress: true、backend: rbd;
  4. 驱动匹配:根据volumes.host字段(如cinder-volume@rbd#ceph)匹配drivers/rbd.py;
  5. RPC初始化:调用utils/rpc_client.py创建BackupManager客户端,目标topic=cinder-backup;
  6. 备份触发:执行rpc_client.cast(context, {'method': 'create_backup', 'args': {...}}),传入卷ID、策略参数;
  7. 状态轮询:每30秒查backups表,直到status IN ('available','error'),超时(默认2h)则标记为timeout。

关键点在于第6步:cast()是异步调用,不等待返回。这避免了脚本因RPC超时而卡死——而原生cinder backup-create用的是call()同步模式,一旦cinder-backup进程夯住,CLI就永远挂起。

2.3 数据库Schema依赖与安全访问控制

cinderback只读取两张表,且字段严格限定,不碰任何敏感字段(如volumes.encryption_key_id):

表名必读字段用途是否可选
volumesid,status,host,size,created_at判断卷可用性、匹配驱动、记录备份时间否
backupsid,volume_id,status,fail_reason,updated_at检测重复任务、失败归因、超时判断否

访问数据库时,脚本强制使用最小权限账号:

CREATE USER 'cinderback'@'%' IDENTIFIED BY 'strong_password'; GRANT SELECT(id,status,host,size,created_at) ON cinder.volumes TO 'cinderback'@'%'; GRANT SELECT(id,volume_id,status,fail_reason,updated_at) ON cinder.backups TO 'cinderback'@'%'; FLUSH PRIVILEGES;

注意:cinderback不支持INSERT/UPDATE任何表。所有写操作(如插入backups记录)均通过RPC由cinder-backup服务完成。这是安全底线——脚本永远不越权修改Cinder核心数据。


3. 运行实操:从零部署到首次成功备份的完整链路

部署cinderback不是复制粘贴就能跑通的事。它深度绑定OpenStack环境,必须按顺序解决四个环境依赖。以下是在CentOS 7 + OpenStack Train版本上的实测步骤(Ubuntu用户请将yum替换为apt)。

3.1 环境准备:四类依赖缺一不可

首先确认基础依赖已安装:

# 安装Python3及pip(OpenStack环境通常已存在) yum install -y python3 python3-pip # 安装MySQL客户端(用于DB连接) yum install -y mysql # 安装RabbitMQ Python客户端(用于RPC) pip3 install pika oslo.messaging # 安装Ceph客户端(仅当使用RBD后端时需要) yum install -y ceph-common

最关键的一步是配置文件生成。cinderback不读取/etc/cinder/cinder.conf,而是要求你提供精简版配置:

# 创建config目录 mkdir -p cinderback/config # 从现有cinder.conf提取关键项(务必脱敏!) grep -E "^(connection|rabbit_|transport_url)" /etc/cinder/cinder.conf \ | sed 's/#.*$//; s/^[[:space:]]*//; /^$/d' \ > cinderback/config/cinder.conf

生成的cinder.conf应只含三类内容:

[database] connection = mysql+pymysql://cinderback:strong_password@cinder-db/cinder?charset=utf8 [DEFAULT] transport_url = rabbit://openstack:password@rabbitmq:5672/ [oslo_messaging_rabbit] rabbit_ha_queues = true

提示:transport_url必须与cinder-backup服务配置完全一致,否则RPC消息发不到正确队列。一个常见翻车点是rabbitmq主机名在/etc/hosts中未解析,导致pika.exceptions.AMQPConnectionError。

3.2 首次运行:带调试的日志与状态验证

首次运行务必加--debug参数,观察每步执行细节:

cd cinderback python3 cinderback.py \ --volume-id 5a3b9c1d-2e4f-5a6b-8c9d-0e1f2a3b4c5d \ --policy daily \ --debug

你会看到类似输出:

[DEBUG] Step 1: Validating volume ID format... OK [DEBUG] Step 2: Connecting to MySQL... OK (cinder-db:3306) [DEBUG] Step 3: Querying volume status... Volume 5a3b9c1d...: status=available, host=cinder-volume@rbd#ceph [DEBUG] Step 4: Loading policy 'daily'... retention=7, compress=True, backend=rbd [DEBUG] Step 5: Initializing RPC client... OK (topic=cinder-backup) [INFO] Step 6: Sending create_backup RPC... backup_id=7f8a9b0c-1d2e-3f4a-5b6c-7d8e9f0a1b2c [INFO] Step 7: Polling backup status... [00:30] status=creating [INFO] Step 7: Polling backup status... [01:00] status=available [SUCCESS] Backup completed: 7f8a9b0c-1d2e-3f4a-5b6c-7d8e9f0a1b2c

验证备份是否真实生成:

# 查看backups表记录 mysql -u cinderback -p cinder -e "SELECT id,volume_id,status,object_count FROM backups WHERE volume_id='5a3b9c1d-2e4f-5a6b-8c9d-0e1f2a3b4c5d';" # 对RBD后端,检查Ceph pool中是否有对象 rbd ls -p backups | grep 7f8a9b0c # 输出类似:7f8a9b0c-1d2e-3f4a-5b6c-7d8e9f0a1b2c.backup

3.3 定时任务配置:crontab与systemd双模式

生产环境推荐用systemd管理,因其能捕获脚本退出码并自动重启:

# 创建service文件 cat > /etc/systemd/system/cinderback-daily.service << 'EOF' [Unit] Description=Cinder Daily Backup Job After=network.target [Service] Type=oneshot User=root WorkingDirectory=/opt/cinderback ExecStart=/usr/bin/python3 /opt/cinderback/cinderback.py --all-volumes --policy daily StandardOutput=journal StandardError=journal [Install] WantedBy=multi-user.target EOF # 启用定时器(每天凌晨2点执行) cat > /etc/systemd/system/cinderback-daily.timer << 'EOF' [Unit] Description=Run Cinder Daily Backup [Timer] OnCalendar=*-*-* 02:00:00 Persistent=true [Install] WantedBy=timers.target EOF systemctl daemon-reload systemctl enable cinderback-daily.timer systemctl start cinderback-daily.timer

若坚持用crontab,请务必添加环境变量和日志重定向:

# 编辑root crontab 0 2 * * * cd /opt/cinderback && /usr/bin/python3 cinderback.py --all-volumes --policy daily >> /var/log/cinderback/daily.log 2>&1

注意:crontab中PATH环境变量极简,python3可能不在/usr/bin下。务必用which python3确认路径,否则出现/bin/sh: python3: command not found。


4. 避坑指南:生产环境踩过的5个真实坑位与解法

这些不是理论假设,而是某公司在200+节点集群上踩出的血泪经验。每条都对应一个grep -r就能复现的真实日志片段。

4.1 现象:备份状态永远卡在creating,backups表updated_at不再更新

原因:cinder-backup服务的RabbitMQ连接被防火墙重置,但服务进程未崩溃,导致RPC消息进入“黑洞队列”。cinderback轮询时发现status='creating'且updated_at超2小时未变,却无法主动取消任务。
解决:在cinderback.py的轮询逻辑中增加超时强制终止:

# 在poll_backup_status()函数内 if status == 'creating': updated_delta = datetime.now() - backup.updated_at if updated_delta.total_seconds() > timeout_sec: # timeout_sec默认7200 # 直接调用RPC cancel_backup rpc_client.cast(context, { 'method': 'delete_backup', 'args': {'backup_id': backup.id} }) logger.error(f"Backup {backup.id} timeout, sent cancel RPC") return 'timeout'

4.2 现象:cinderback报pika.exceptions.ConnectionClosedByBroker: (406, 'PRECONDITION_FAILED')

原因:RabbitMQ启用了x-message-ttl策略,但cinderback的RPC队列未设置TTL,消息堆积后被Broker强制丢弃。
解决:在utils/rpc_client.py中创建队列时显式声明TTL:

# 修改queue_declare参数 channel.queue_declare( queue='cinder-backup', durable=True, arguments={'x-message-ttl': 3600000} # 1小时毫秒 )

4.3 现象:LVM卷备份成功,但恢复时提示device-mapper: reload ioctl failed: Invalid argument

原因:drivers/lvm.py使用dd if=/dev/vg/lv of=/backup/path.img,但未在dd后执行sync,导致部分块未刷盘,镜像文件损坏。
解决:在create_backup()方法末尾添加强制同步:

# drivers/lvm.py 第42行 subprocess.run(['dd', 'if='+src_path, 'of='+dst_path, 'bs=1M'], check=True) subprocess.run(['sync'], check=True) # 关键!

4.4 现象:cinderback能连DB,但查不到backups表,报Table 'cinder.backups' doesn't exist

原因:OpenStack版本升级(如Rocky→Train)后,Cinder将备份表从cinder库迁移到cinder_api库,但脚本仍连旧库。
解决:在config/cinder.conf中显式指定connection指向新库:

[database] connection = mysql+pymysql://cinderback:pwd@cinder-db/cinder_api?charset=utf8

4.5 现象:执行--all-volumes时,脚本在第37个卷处崩溃,报UnicodeEncodeError: 'ascii' codec can't encode character u'\u2019'

原因:某个卷的display_name包含中文或特殊符号(如test’s-volume中的右单引号),而MySQL连接未设置charset=utf8。
解决:在utils/db.py的create_engine()中强制编码:

# utils/db.py 第15行 engine = create_engine( db_url, encoding='utf-8', # 显式声明 echo=False, pool_pre_ping=True )

5. 二次开发:如何为自定义存储后端添加驱动并验证

当你需要支持自家硬件(如某国产分布式存储)时,cinderback的drivers/目录就是你的改造入口。整个过程分三步:写驱动 → 注册驱动 → 验证行为,全程无需动主流程代码。

5.1 驱动编写规范:必须实现的4个方法

新建drivers/my_storage.py,继承基类BaseBackupDriver(定义在drivers/__init__.py):

# drivers/my_storage.py from drivers import BaseBackupDriver import subprocess import json class MyStorageDriver(BaseBackupDriver): def __init__(self, volume_host): super().__init__(volume_host) # 从volume_host解析存储IP和认证信息 # 如 volume_host = "cinder-volume@my-storage#10.0.0.100" self.storage_ip = volume_host.split('#')[-1] def create_backup(self, volume_id, backup_id, policy): """创建备份:调用厂商CLI导出快照""" try: # 调用厂商提供的导出命令 cmd = [ '/opt/my-storage/bin/export-snapshot', '--volume-id', volume_id, '--backup-id', backup_id, '--compress', str(policy.get('compress', False)), '--output-dir', '/backup/my-storage/' ] result = subprocess.run(cmd, capture_output=True, text=True, check=True) # 解析厂商返回的JSON状态 status = json.loads(result.stdout) if status.get('code') != 0: raise Exception(f"Export failed: {status.get('message')}") except subprocess.CalledProcessError as e: raise Exception(f"CLI execution failed: {e.stderr}") def cleanup_backup(self, backup_id): """清理备份:删除厂商侧快照""" subprocess.run([ '/opt/my-storage/bin/delete-snapshot', '--backup-id', backup_id ], check=True) def get_backup_size(self, backup_id): """获取备份大小(用于日志统计)""" size_file = f"/backup/my-storage/{backup_id}.size" if os.path.exists(size_file): with open(size_file) as f: return int(f.read().strip()) return 0 def validate_volume(self, volume): """预检:确认卷在厂商存储中状态正常""" # 调用厂商健康检查API return True # 或返回具体错误信息

注意:所有方法必须是实例方法(带self),且不能抛出未捕获的异常。create_backup()是唯一必实现方法,其余为可选。

5.2 驱动注册:让主程序识别新类型

cinderback通过volume_host字段匹配驱动。你需要确保Cinder卷的host包含可识别的标识:

-- 在Cinder数据库中,为某卷设置host(需cinder-volume服务重启) UPDATE volumes SET host='cinder-volume@my-storage#10.0.0.100' WHERE id='5a3b9c1d-2e4f-5a6b-8c9d-0e1f2a3b4c5d';

然后在cinderback.py的驱动映射字典中添加一行:

# cinderback.py 第89行 DRIVER_MAP = { 'lvm': 'drivers.lvm.LVMDriver', 'rbd': 'drivers.rbd.RBDDriver', 'pure': 'drivers.pure.PureDriver', 'my-storage': 'drivers.my_storage.MyStorageDriver', # 新增 }

5.3 验证方法:三层断言确保功能完备

不要只测“能跑”,要验证行为正确性、错误处理健壮性、日志可追溯性:

验证层级方法期望结果工具
单元测试运行pytest tests/test_my_storage.pytest_create_backup_success通过,test_create_backup_failure捕获异常pytest + mock
集成测试手动执行python cinderback.py --volume-id <id> --policy testbackups表生成记录,status='available',/backup/my-storage/下有文件MySQL CLI +ls
破坏性测试断开厂商存储网络,再执行备份脚本退出码=1,日志含MyStorageDriver.create_backup failed: Connection refusedecho $?+tail -n 20 /var/log/cinderback/main.log

一个关键技巧:在drivers/my_storage.py中加入调试日志,但不提交到生产:

def create_backup(self, volume_id, backup_id, policy): logger.debug(f"[MyStorage] Exporting {volume_id} to {backup_id} on {self.storage_ip}") # ... 实际逻辑

这样在调试时--debug能看到厂商交互细节,上线前注释掉即可。

从那以后我每次为新存储类型写驱动,都强制走一遍这三层验证——哪怕只是改一行cmd参数。因为OpenStack环境里,print("success")和真正ls出备份文件,中间隔着17个可能崩掉的服务。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进