ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

OpenStack Cinder 自动化备份:Python 快照、轮转与恢复校验

OpenStack Cinder 自动化备份:Python 快照、轮转与恢复校验

简介:cinderback 是一款面向 OpenStack 运维与云平台开发者的 Cinder 卷备份/还原辅助脚本,用于简化管理员和租户视角下的批量备份流程,并针对 Cinder Backup Service 现有局限给出变通做法。它可按凭据遍历所有可访问租户执行批量备份与恢复,支持管理员将备份对所属租户隐藏或恢复可见,并提供备份轮换(限制每卷自动备份保留数量)、按原卷 ID 或备份 ID 还原、保留原卷名称与描述、借助临时快照处理使用中卷,以及备份元数据一次性导出/导入等功能,运行环境仅需 Python 2.7 与 cinderclient v1.1.1 以上版本。压缩包共 4 个文件、约 11KB,以 cinderback.py 主脚本为核心,辅以 README 说明文档、requirements.txt 依赖清单与 .gitignore 配置,体量轻巧,便于直接阅读源码并按需改造。已有 315 人学习,适合想理解 Cinder 备份编排逻辑,或需要一套参考脚本快速搭建备份流程的初、中级运维人员。

1. 别等卷没了才想起快照:一个 Python 备份助手能接住什么

某公司的私有云上,一块 500G 的数据盘被误删,工单转过来时运维第一反应是「Cinder 不是有快照吗」,翻完发现只有零星几个人手动打过快照,最近一份还是三个月前。这类事故在自建 OpenStack 的环境里一点都不稀奇:Cinder 的备份能力一直在,缺的是把它按计划、可追溯、可恢复地跑起来的那层胶水。cinderback 就是这层胶水,一个 Python 写的 Cinder 备份脚本助手,负责选卷、打快照、调 cinder-backup 或导出镜像、写索引、按保留策略轮转,最后给你一条能验证恢复的命令。它适合手里有 Cinder 却只有「手动 snapshot-create」这一招的运维和二次开发同学,也适合想看懂 OpenStack 卷备份链路长什么样的后端开发。

2. 项目定位与技术选型:一个 Python 脚本在 Cinder 旁边干的四件事

先把话说直白:cinderback 不是备份存储本身,它不存数据。真正落盘的是 Cinder 的 snapshot、cinder-backup 服务或者 Glance 镜像,cinderback 负责的是「什么时候、对哪些卷、用什么方式、留多久、怎么确认能恢复」这套调度与编排逻辑。这个定位决定了它的代码结构不会太复杂,但也决定了很多坑不在代码里,而在 OpenStack 各服务的状态机上。

2.1 四个动作:快照、备份、导出、可恢复性校验

第一件事是选卷。一个几十上百卷的环境,不能靠人写清单,脚本要能从 Cinder 拉全量卷,按状态、大小、metadata 打标过滤掉不该备份的(比如临时盘、缓存盘)。过滤规则必须可配置,因为每个环境的命名习惯都不一样。

第二件事是打快照。Cinder 的 snapshot 是后端存储层面的 COW,快、便宜,但依赖后端,不能当异地备份。cinderback 把快照当作备份链路的第一步,而不是终点。

第三件事是转成真正的备份。这里有三条路:继续用 cinder-backup 服务(支持增量、可跨后端)、把快照导成 Glance 镜像、或者只留快照。三种方式的成本、恢复路径、对后端的依赖完全不同,脚本用export.mode这一个开关来切。

第四件事是记录与轮转。每次备份写一条索引记录,包含卷 ID、快照 ID、备份 ID、大小、时间、父备份,恢复时靠这份索引反查;轮转按时间而不是按名字删,避免「名字排序把最新一份删了」这种低级事故。最后配一条verify子命令,把备份真的恢复成一个卷再删掉,作为定期演练。

这四件事串起来,才是「备份」这个词的完整含义。少任何一环,你拥有的都只是「看起来有备份」。

2.2 依赖与技术选型:为什么是 keystoneauth1 + cinderclient 而不是裸 requests

用requests直接怼 Cinder REST API 当然可以,但你要自己处理 Keystone 的 token 刷新、catalog 里找 endpoint、微版本 header、分页 marker,写完基本等于重写半个 SDK。这个项目走的是官方客户端路线,依赖集中在三五个包上:

依赖用途备注
keystoneauth1认证插件、Session、超时与重试密码插件是password,也有token方式
python-cinderclient卷、快照、备份的 API 封装用 v3,微版本影响字段可见性
PyYAML读配置文件比 ini 好写嵌套结构
click子命令与参数解析run / plan / verify / prune四个子命令
tenacity(可选)自定义重试策略适合对 429 单独做退避

选cinderclient的直接好处是版本兼容的脏活它替你干了;坏处是它对异常的分类比较粗,很多错误最后都变成keystoneauth1.exceptions.http.HttpError,所以脚本里必须自己包一层错误归类,把 401/403/404/429/500 分开处理,否则日志里全是一坨看不出所以然的 traceback。

另一个容易被忽略的点是微版本。cinder_client.Client("3", ...)拿到的字段集合跟微版本有关,比如某些版本下卷的metadata、快照的progress行为不完全一致。稳妥的做法是在配置里显式指定api_version: 3.50之类的值,并且让脚本在启动时打一行日志把实际微版本打出来——出问题时这一行日志能省掉半小时排查。

2.3 目录结构:照着这份文件清单对源码

源码包的目录基本是按「入口 → 编排 → 动作 → 输出」分的,读的时候按这个顺序读,不要从utils.py开始啃:

cinderback/ ├── __main__.py # python -m cinderback 的入口,只做 click 转发 ├── cli.py # 四个子命令定义、参数校验、退出码 ├── config.py # YAML 加载 + 默认值合并 + 必填项校验 ├── session.py # Keystone 认证与 cinderclient 构造 ├── select.py # 卷筛选、分批 ├── snapshot.py # 快照创建与状态轮询 ├── export.py # snapshot / backup / image 三种导出模式 ├── index.py # jsonl 索引读写、按卷反查 ├── retention.py # 保留策略计算(只算不删,删除单独执行) ├── verify.py # 恢复演练 ├── errors.py # 错误分类与退出码映射 └── exporters/ ├── base.py # Exporter 抽象基类,二次开发的扩展点 └── cinder_backup.py # 默认实现

cli.py里所有子命令共用一套--config、--dry-run、--log-level,业务参数各管各的。index.py是唯一的持久化状态,其它模块都是无状态函数,这样并发跑的时候不用考虑共享内存,只考虑索引写入的追加安全(单进程写、按行追加、写完 flush)。

exporters/目录是为二次开发留的口子,默认只实现 cinder-backup 一种,想加 S3、加 NFS、加自建对象存储,都是往这里塞一个类。

3. 安装与首跑:从零到一次 dry-run 的十分钟

装这类工具最忌讳一上来就往生产凭据上怼。正确顺序是:先装依赖、准备一个只读权限的服务账号、跑plan(只列不干)、再跑run --dry-run、最后才真跑。每一步都能独立验证,出问题知道停在哪。

3.1 环境与凭据准备

Python 版本建议 3.9 以上,用 venv 隔离,别往系统 Python 里装,OpenStack 控制节点上的系统包依赖很容易被搞坏:

python3 -m venv /opt/cinderback/venv /opt/cinderback/venv/bin/pip install -U pip /opt/cinderback/venv/bin/pip install -e /opt/cinderback/src # 源码包方式安装 /opt/cinderback/venv/bin/cinderback --version

-e是开发模式安装,改代码不用重装,适合先跑通再改造;正式部署时去掉-e走普通安装。

凭据上,建议单独开一个服务账号,只授予backup相关角色,并且给到目标项目的访问权限。这里有个常见误解:以为备份是管理员行为,其实snapshot.create和backup.create都是在项目维度下的操作,跨项目要开all_tenants就得有管理员权限,权限给大了风险也随之变大。我一般先用单项目跑通,再评估要不要开all_tenants。

密码不要写进 YAML。配置里只写环境变量名,实际值通过 systemd 的EnvironmentFile或容器 secret 注入:

export CINDERBACK_PASSWORD='********'

3.2 最小可运行命令

四个子命令里,plan是最安全的,它只拉卷列表、套用过滤规则、打印将要备份的卷和预计快照数量,不产生任何资源:

cinderback --config /etc/cinderback/cinderback.yaml plan --tag daily

run是主命令,加--dry-run时会把每一步「打算做什么」打进日志但不真的调创建接口:

cinderback --config /etc/cinderback/cinderback.yaml run --tag daily --dry-run cinderback --config /etc/cinderback/cinderback.yaml run --tag daily --max-workers 4

verify和prune是配套动作,一个验证可恢复性,一个清理过期备份:

cinderback --config /etc/cinderback/cinderback.yaml verify --backup-id <id> --keep-volume cinderback --config /etc/cinderback/cinderback.yaml prune --tag daily --dry-run

--tag这个参数贯穿全部命令,它是同一批备份的幂等键:同一个 tag 当天已经成功备份过的卷,再跑一次会跳过而不是重复打快照。很多「跑了两遍产生两份快照」的事故,都是因为没设计这个键。

3.3 配置文件逐字段说明

配置分四块:认证、备份、导出、保留。下面这份是能直接改着用的最小版本:

auth: auth_url: https://keystone.internal:5000/v3 username: svc-cinderback password_env: CINDERBACK_PASSWORD project_name: backup-ops user_domain_name: Default project_domain_name: Default region_name: RegionOne api_version: "3.50" api_timeout: 60 retries: 3 backup: tag: daily include_status: ["available", "in-use"] exclude_metadata: cinderback: "off" min_size_gb: 1 batch_size: 20 snapshot: force: true wait_timeout: 1800 poll_interval: 5 export: mode: backup # snapshot | backup | image backup_container: cinderback incremental: true retention: keep_last: 7 keep_daily: 14 index: path: /var/lib/cinderback/index.jsonl

字段含义和改动影响,挑几个容易踩的讲:

字段作用改错会怎样
password_env指向环境变量名,不是密码本身填成密码明文会泄露,填错名字启动就报 KeyError
api_versionCinder 微版本太低会让某些字段拿不到,太高会被服务端拒绝
include_status允许备份的卷状态把error也加进去,等于给坏卷打快照
forcein-use 卷是否强制快照设 false 时挂载中的卷会直接 400
incremental是否走增量备份依赖同卷上一个备份存在,首次必然是全量
keep_last每卷至少保留几份设 0 又开了 prune,容易把自己删空

index.path是整个工具唯一的状态文件,放在本地盘上要保证有空间;如果准备多节点跑,这里就要换成共享存储或者干脆换成一个小的 SQLite,后面二次开发一节会讲。

3.4 日志、dry-run 与退出码

日志按「一个卷一行摘要,异常单独一段 traceback」的粒度打。正常跑完你会看到每卷的volume_id / snapshot_id / backup_id / size_gb / duration,失败的行会带错误分类。dry-run 模式下只打前三项加DRY-RUN标记,方便先对一遍卷清单。

退出码建议固定成三档,方便外层调度判断:0全部成功,2部分失败(有卷失败但整体跑完),3认证或配置错误(一条都没跑)。区别在于2要人工看日志补跑,3是环境问题重跑也没用。很多自研脚本把所有失败都 exit 1,结果监控告警分不清是「配置错」还是「个别卷挂了」,这个细节值得一开始就定好。

3.5 定时任务与并发度设置

日备一般放凌晨低峰,用 cron 或 systemd timer 都行。cron 的写法要注意把 stdout/stderr 都重定向,否则失败信息会被 mail 吞掉:

# /etc/cron.d/cinderback 17 2 * * * root /opt/cinderback/venv/bin/cinderback \ --config /etc/cinderback/cinderback.yaml \ run --tag daily --max-workers 4 \ >> /var/log/cinderback/daily.log 2>&1

--max-workers是并发度,控制同时处理几个卷。它不是越大越好:每个并发背后都是一个backup.create请求加后端存储的复制任务,开到 16 很容易把 cinder-api 打出 503,或者让后端存储的队列堆满。经验值是不超过 4,卷少的环境 2 就够,具体看 cinder-api 的 worker 数和后端存储的吞吐。

4. 核心链路拆解:一个卷从被选中到写进索引的完整路径

这一章按数据流走一遍:会话怎么建、卷怎么选、快照怎么等、导出怎么落、索引怎么写、轮转怎么算。每一步都给出关键代码和参数含义,改的时候知道自己动的是哪一段。

4.1 会话封装:超时、重试与限流

认证是整个链路的第一道坎。keystoneauth1 的Session已经把 token 刷新做了,但默认配置在长时间任务里不够用:默认超时可能偏短,重试策略对 429 的处理也不一定符合预期。这一层建议自己封一下,把配置项集中管:

# cinderback/session.py import os from keystoneauth1 import loading, session as ks_session from cinderclient import client as cinder_client def build_cinder(cfg: dict): """按 auth 段配置构造带超时与重试的 cinderclient v3 客户端。""" auth_cfg = cfg["auth"] loader = loading.get_plugin_loader("password") auth = loader.load_from_options( auth_url=auth_cfg["auth_url"], username=auth_cfg["username"], password=os.environ[auth_cfg["password_env"]], # 密码只从环境变量读 project_name=auth_cfg["project_name"], user_domain_name=auth_cfg.get("user_domain_name", "Default"), project_domain_name=auth_cfg.get("project_domain_name", "Default"), ) sess = ks_session.Session( auth=auth, timeout=auth_cfg.get("api_timeout", 60), retries=auth_cfg.get("retries", 3), # 连接类错误重试次数 discovery_cache=False, # 长任务不复用旧 endpoint ) return cinder_client.Client( "3", session=sess, region_name=auth_cfg.get("region_name"), api_version=auth_cfg.get("api_version"), )

这段代码里三个参数值得单独拎出来说。timeout是单次 HTTP 请求的超时,不是整个备份任务的超时,写小了会在后端慢的时候频繁掐断;retries只对连接错误和部分 5xx 生效,别指望它能救 401;discovery_cache=False是我踩过坑之后加的——跑几个小时的批处理任务时,如果 token 过期后重新发现 endpoint 拿到了缓存里的旧地址,会莫名其妙报连接失败。

限流不在这段里做,而在外层按批做:一批处理完sleep一小会儿再进下一批,用配置里的batch_size控制节奏。这比在 HTTP 层做令牌桶简单得多,效果也够用。

4.2 卷筛选与分批

筛选逻辑看着简单,但它是决定「备份覆盖面对不对」的地方。默认规则是:状态必须在白名单里、metadata 没被显式标记为关闭、大小超过下限。加上all_tenants与否看权限,代码里用search_opts传:

# cinderback/select.py def select_volumes(cinder, cfg: dict): """按状态、metadata、大小过滤出本次要备份的卷。""" backup_cfg = cfg["backup"] allow = set(backup_cfg.get("include_status", ["available", "in-use"])) deny_meta = backup_cfg.get("exclude_metadata", {}) min_size = backup_cfg.get("min_size_gb", 1) picked = [] for vol in cinder.volumes.list(search_opts={"all_tenants": 1}): if vol.status not in allow: # error / deleting 一律跳过 continue if vol.size < min_size: # 太小的卷不值得单独管 continue if any(vol.metadata.get(k) == v for k, v in deny_meta.items()): continue # metadata 打标豁免 picked.append(vol) return picked def batches(seq, size): """把卷列表切成固定大小的批,配合批次间 sleep 做限流。""" for i in range(0, len(seq), size): yield seq[i:i + size]

search_opts里的all_tenants=1需要管理员权限,单项目账号要去掉这一项,否则会 403。exclude_metadata用字典而不是列表,是为了支持「某个 key 等于某个值才排除」的语义,比只按 key 排除灵活——比如cinderback: "off"和cinderback: "weekly"可以走不同的备份策略。

分批的意义不只是限流。它让「部分失败」变得可控:一批里挂了两个卷,后面的批次照常跑,最后统一汇总退出码,不会因为一个卷的问题导致整轮都不备份。

4.3 快照创建与状态轮询

快照是整条链路里最容易卡住的一步。Cinder 的snapshots.create返回时状态是creating,真正可用要等后端存储完成 COW,几十秒到几十分钟都可能。这里的写法是「创建 + 轮询 + 明确失败」:

# cinderback/snapshot.py import time class SnapshotTimeout(RuntimeError): pass def create_snapshot(cinder, vol, cfg: dict): """对单个卷打快照,并阻塞到 available 或明确失败。""" scfg = cfg["snapshot"] tag = cfg["backup"]["tag"] snap = cinder.snapshots.create( volume_id=vol.id, force=scfg.get("force", True), # in-use 必须 force name=f"{vol.name or vol.id}-{tag}", description=f"cinderback:{tag}", metadata={"cinderback_tag": tag, "volume_size": str(vol.size)}, ) deadline = time.monotonic() + scfg.get("wait_timeout", 1800) interval = scfg.get("poll_interval", 5) while time.monotonic() < deadline: cur = cinder.snapshots.get(snap.id) if cur.status == "available": return cur if cur.status in ("error", "error_deleting"): raise RuntimeError(f"snapshot {cur.id} 状态 {cur.status}") time.sleep(interval) raise SnapshotTimeout(f"snapshot {snap.id} 等待超过 {scfg['wait_timeout']}s")

force=True是必须的,否则对挂载中的卷调用会直接 400。wait_timeout别设太大,1800 秒是个合理上限——真卡到半小时以上,基本是后端存储侧的问题,继续等也不会好,不如尽早报错让告警响起来。poll_interval设 5 秒,太密会给 cinder-api 增加无谓压力,太稀会让整体时长被轮询拖长。

metadata里塞cinderback_tag和volume_size是有意为之:快照本身要知道自己属于哪一批备份,恢复时也方便反查原始大小,不用再回去查卷(卷可能已经被删了)。

4.4 导出:snapshot / backup / image 三种模式

快照打完,怎么落地成「能拿走的备份」有三种选择,脚本用一个开关切。三种模式的差别建议在部署前就想清楚:

模式落点是否增量恢复路径适用场景
snapshotCinder 快照否快照建卷同后端快速回滚,成本最低
backupcinder-backup 后端是备份恢复成卷需要跨后端、要增量、要长期保留
imageGlance 镜像否镜像建卷要跨区域搬、要给其他平台用

backup模式是默认推荐的,因为它支持增量,长期跑下来空间占用最可控:

# cinderback/export.py def export_via_backup(cinder, snap, cfg: dict): """走 cinder-backup 服务,找到同卷上一份备份作为增量父。""" ecfg = cfg["export"] parent = None if ecfg.get("incremental", True): parent = _last_backup_of_volume(cinder, snap.volume_id, cfg["backup"]["tag"]) bak = cinder.backups.create( None, # 名字传 None,下面显式指定 snapshot_id=snap.id, name=f"{snap.volume_id}-{cfg['backup']['tag']}", incremental=parent is not None, parent_id=parent.id if parent else None, container=ecfg.get("backup_container", "cinderback"), ) return _wait_backup(cinder, bak.id, cfg["snapshot"]["wait_timeout"])

snapshot_id而不传volume_id,是因为已经有快照了,让备份服务直接从快照读,避免再对在线卷做一次一致性读取。incremental和parent_id必须成对出现:只开增量不指定父,服务端要么报错要么退化成全量。container决定了备份写到后端存储的哪个容器/池里,多环境共用一个 cinder-backup 时这个字段是必要的隔离手段。

image模式稍微绕一点,因为 Cinder 没有「快照直接上传 Glance」的稳定接口,常见做法是先从快照建一个临时卷,再对这个卷upload_to_image,最后删临时卷:

def export_via_image(cinder, snap, cfg: dict): """快照 -> 临时卷 -> Glance 镜像 -> 删临时卷。""" tmp = cinder.volumes.create( size=snap.size, snapshot_id=snap.id, name=f"cb-tmp-{snap.id[:8]}", metadata={"cinderback_tmp": "1"}, ) _wait_volume(cinder, tmp.id, "available", cfg["snapshot"]["wait_timeout"]) image, _resp = cinder.volumes.upload_to_image( tmp, force=True, image_name=f"cb-{snap.volume_id}-{cfg['backup']['tag']}", container_format="bare", disk_format="raw", visibility="private", ) _wait_image_uploaded(cinder, tmp.id, cfg["snapshot"]["wait_timeout"]) cinder.volumes.delete(tmp.id) return image

disk_format="raw"是为了恢复时不用转格式,代价是镜像体积等于卷大小,Glance 后端要留够空间。_wait_image_uploaded这一步不能省——很多「Glance 里出现 0 字节镜像」的事故,就是upload_to_image返回后立刻删临时卷导致的,卷还在uploading状态就被删,镜像自然残缺。临时卷一定要打cinderback_tmp标记,避免下次运行被当成正常卷备份进去。

4.5 索引侧车文件与幂等键

索引是这个工具的「黑匣子」,恢复的时候全靠它。格式选 jsonl 而不是数据库,理由是追加写天然安全、出问题能直接用 grep 看、迁移不需要 dump。一条记录长这样:

{"ts":"2025-06-12T02:17:41Z","tag":"daily","volume_id":"9f2c...","volume_name":"data-01","size_gb":500,"snapshot_id":"5a71...","backup_id":"c0d3...","mode":"backup","parent_id":null,"duration_s":412,"status":"success"}

写入时机很关键:快照创建成功后写一条 pending,导出成功后更新为 success。如果只在最后写一条,中途进程被杀就完全不知道这个快照是谁打的、该不该删。实现上不是真的「更新」,而是追加一条同snapshot_id的新记录,读取时按ts取最后一条——追加写比原地改安全得多,也不怕写一半断电。

幂等键用(volume_id, tag, 日期)三元组。run开始前先扫一遍索引,把当天已经 success 的卷排除掉,这样:

  • 定时任务重跑不会重复打快照;
  • 部分失败后补跑只处理失败的那些;
  • 多节点误配同时跑,后启动的那个基本会全部跳过。

扫描索引这步在卷多的时候会慢一点,但比起重复备份产生的空间和清理成本,这点开销值得。

4.6 保留策略:按时间排序而不是按名字

轮转这块最常见的翻车是「按名字排序然后删最老的」。名字里带时间戳看着能用,但一旦有人手动改过名字、或者跨时区跑过、或者 tag 不一样,排序结果就不对了。正确做法是按记录里的ts排:

# cinderback/retention.py def plan_prune(records, keep_last=7, keep_daily=14): """返回待删除的记录列表;只算不删,删除由调用方执行。""" records = sorted(records, key=lambda r: r["ts"], reverse=True) keep, seen_days = set(), set() for i, r in enumerate(records): if i < keep_last: # 最近 N 份无条件保留 keep.add(r["backup_id"]) continue day = r["ts"][:10] if day not in seen_days and len(seen_days) < keep_daily: seen_days.add(day) # 每天保留最早成功的那一份 keep.add(r["backup_id"]) return [r for r in records if r["backup_id"] not in keep]

keep_last是「无论如何留几份」,防止保留策略把最近的备份删光;keep_daily是「按天去重后留几天」,实现的是 GFS 里最简的日备层。这两个参数要一起看:keep_last=7, keep_daily=14意味着最近 7 份必留,再往前每天留一份留 14 天。

函数只返回待删列表,真正删除是另一个动作,而且prune默认配合--dry-run让人先看一眼。这个「算和删分离」的设计不是洁癖,是后悔药:删备份是不可逆操作,多一步确认能挡掉大部分手滑。

5. 避坑与排查:五条跑在生产上的血泪记录

下面五条都是这类脚本在生产上真实会遇到的,每条按「现象 → 原因 → 解决」写。看的时候对照自己的环境,能提前避开两三条就够本了。

5.1 快照卡在 creating 半小时不动

现象:日志停在snapshot xxx 状态 creating,wait_timeout到了抛异常,同一批后面的卷全部跳过。

原因:九成不是 Cinder API 的问题,而是后端存储侧卡住——LVM 后端 thin pool 快满、Ceph 集群 PG 不健康、或者存储侧的快照队列排满。API 只是如实返回creating,它也不知道后端发生了什么。

解决:第一步看cinder-volume的日志,找对应卷 ID 的报错;第二步查后端容量水位。脚本侧要做的是把超时设成合理值(1800s 足够),超时后不要无限重试,直接进失败汇总并让监控告警。同时给SnapshotTimeout单独的错误类型,日志里带上volume_id和snap_id,方便运维直接定位到后端。

5.2 备份显示 success,恢复出来文件系统报错

现象:verify把备份恢复成卷、挂上去,fsck一堆 inode 错误,或者数据库直接起不来。

原因:对in-use的卷打快照,拿到的只是块设备的瞬时状态,文件系统缓存和数据库的 WAL 还悬在半空。Cinder 层面快照是「成功」的,但数据一致性没人保证。

解决:备份前做静默。文件系统用fsfreeze,数据库用对应的备份锁(或者干脆先停写),把静默动作做成钩子,包在快照前后。如果环境里做不到静默,就在索引里记一个consistent: false字段,恢复演练时对这类备份降低期望,别拿它当准生产数据用。这一条是血泪经验:不带静默的在线快照,恢复出来能不能用纯看运气。

5.3 跑到第 40 个卷突然一片 401

现象:前 39 个卷正常,第 40 个开始每个都报Unauthorized,重跑一次又好了。

原因:Keystone token 默认有效期一小时,长任务跑到后面 token 过期。keystoneauth1 的 Session 本该自动重认证,但如果中途缓存了旧 endpoint,或者代码里手动缓存了 client 对象跨批次复用,就会拿着过期凭据继续打。

解决:不要在模块级缓存 client,按批构造或者定期重建;discovery_cache=False关掉 endpoint 缓存;把 401 单独归类,遇到时重建一次会话重试当前卷,而不是整批失败。更稳妥的做法是控制单轮任务时长在一小时以内,卷特别多的环境拆成多个 tag 分批跑。

5.4 并发开到 16,cinder-api 开始 503

现象:--max-workers 16跑起来,日志里大量 503 和 429,成功率反而比 2 并发时低。

原因:每个并发对应一次backup.create,背后是后端存储的复制任务。cinder-api 的 worker 数是固定的,突发请求打满队列后开始拒绝;后端存储的并发复制任务也有上限,超了就是排队甚至失败。

解决:把max_workers降到 4 以内,批次之间加几秒 sleep 做削峰。429 用指数退避重试(带随机抖动,避免同一时刻所有线程一起重试),5xx 不要盲目重试,先确认后端健康。并发度的正确值跟环境强相关,别抄别人的数字,从 2 开始往上试,看 cinder-api 的响应时间和错误率拐点在哪。

5.5 轮转把最新的一份删了

现象:prune跑完,索引里最新的一份备份不见了,恢复时找不到最近的。

原因:用名字或 ID 排序代替时间排序;或者keep_last配成 0;或者索引里混进了 pending 记录,被当成正常的参与排序。

解决:排序一律用ts,并且解析成带时区的时间对象再比,别用字符串比(跨时区会错);keep_last给个下限校验,配 0 直接拒绝启动;plan_prune只处理status == "success"的记录,pending 和 failed 单独走清理逻辑。prune永远先跑--dry-run看一遍待删清单,这一步花不了几秒。

6. 二次开发:加一个后端、一个钩子和一条指标

这套代码留的扩展点不多,但够用,主要是exporters/下的抽象基类和几个生命周期钩子。想接自己的对象存储,实现一个Exporter子类,注册成 entry point 就能在配置里用mode: s3切过去:

# cinderback/exporters/base.py from abc import ABC, abstractmethod class Exporter(ABC): name = "base" @abstractmethod def export(self, ctx: dict) -> dict: """ctx 含 cinder 客户端、snapshot 对象、配置;返回待写入索引的字段。""" def cleanup(self, record: dict) -> None: """导出失败或轮转时的清理动作,默认什么都不做。"""
# pyproject.toml [project.entry-points."cinderback.exporters"] s3 = "cinderback_s3:S3Exporter"

ctx里我会固定塞三样东西:cinder(客户端)、snapshot(快照对象)、volume(原始卷对象),再带上config。这样导出实现里既能拿快照 ID 走 API 读数据,也能拿卷的 metadata 决定加密策略或者存储桶。cleanup单独留一个方法而不是塞在export的finally里,是因为轮转阶段也需要复用同一套清理逻辑。

钩子是第二个扩展点,加在快照前后,用来做静默和告警:

HOOKS = {"pre_snapshot": [], "post_snapshot": []} def register(event: str, fn): HOOKS[event].append(fn) def fire(event: str, **kwargs): for fn in HOOKS[event]: fn(**kwargs) # 钩子异常不吞,直接让当前卷失败

pre_snapshot里挂fsfreeze或数据库锁,post_snapshot里解冻并打点。钩子异常不捕获是有意的:静默失败还继续打快照,等于生产一份不可用的备份,比直接失败更危险。

第三个是指标。最小可用的是把每轮的duration_s、成功数、失败数打成一行结构化日志,让日志采集侧去算;要接监控就在run结束时推一次:

from prometheus_client import CollectorRegistry, Gauge, push_to_gateway def push_metrics(stats: dict, gateway: str, job: str = "cinderback"): reg = CollectorRegistry() g = Gauge("cinderback_last_run_seconds", "上一轮耗时", ["tag"], registry=reg) g.labels(stats["tag"]).set(stats["duration_s"]) Gauge("cinderback_failed_volumes", "失败卷数", registry=reg).set(stats["failed"]) push_to_gateway(gateway, job=job, registry=reg)

failed这个计数器比耗时更值得盯——耗时波动往往是后端慢,失败数上涨才是真出问题。改完这些记得跑一遍verify,别只信日志里的 success。

我自己的习惯是:任何对export.py和retention.py的改动,上线前必须拿一个测试卷完整走一遍「备份 → 恢复 → 挂载 → fsck」,哪怕只改了一行排序。备份工具最大的风险不是跑不起来,而是跑起来看着正常、真出事时发现恢复不了。希望帮到你。

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

RELATED READING

延伸阅读

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