简介:面向 OpenStack 运维人员与云平台开发者的 Cinder 卷备份、还原辅助脚本,适合需要批量处理多租户卷备份并缓解 Cinder 备份服务限制的使用者。它支持按租户或管理员权限备份与还原卷,管理员可将备份对属主租户隐藏并灵活恢复;还提供备份轮换、按原始卷编号或备份编号还原、保留原卷名称与描述、借助临时快照处理使用中卷,以及自动备份元数据的集中导出与导入。包内共 4 个文件,以 1 个主脚本、1 个文本依赖清单、1 个说明文档和 1 个版本控制忽略配置为主,压缩包约 11KB,便于阅读、部署与二次改造。已有 315 人学习,运行仅需 Python 2.7 与 cinderclient 1.1.1 或更高版本,可作为理解备份工作流、验证备份轮换策略、排查租户可见性问题、搭建批量运维脚本的参考实现。
1. 从凌晨两点那次翻车说起:cinderback 是什么样的 Cinder 备份脚本助手
凌晨两点被叫起来处理一张卷的误删,翻遍备份列表才发现最近一次成功备份停在 11 天前——脚本早就在某次 401 之后静默挂掉了,没有任何人知道。cinderback 这个 Cinder 备份脚本助手就是冲着这类场景写的:一个 Python 命令行工具,直接调 OpenStack Cinder 的 REST 接口,把卷枚举、备份创建、状态轮询、过期清理和清单导出串成一条可以反复重跑的流水线。它不适合要搭完整备份平台的大团队,更适合手里有几十到几百张卷、运维还靠 crontab 撑着的中小规模环境:你给它一份 YAML,它告诉你今晚该备哪些卷、备完是不是真的 available、七天前那批该不该删。
2. 项目定位与工程骨架:为什么直连 Cinder REST 而不是包一层 CLI
2.1 它接管的六件事
先把这个工具的职责边界划清楚,否则二次开发时很容易越改越乱。cinderback 只做六件事:从 Keystone 拿 token 并按过期时间续期;按名字正则、挂载状态、卷状态筛出这一轮该备份的卷;对每张卷发起一次 backup 创建请求;轮询 backup 记录的状态直到 available 或 error;按保留策略挑出该删的旧备份并删掉;最后把这一轮的卷、备份 ID、耗时、失败原因导成 JSON 和 CSV,方便接监控或者交给同事对账。
它刻意不碰的东西也值得说:不做备份数据的落地存储(那是 cinder-backup 服务和后端的事),不做跨 region 复制,不做卷快照的调度(快照和备份是两条链路)。这个边界让整个项目控制在一千行出头的 Python 里,新人读一遍就能改。
2.2 技术选型:requests + PyYAML + 标准库线程池
很多人第一反应是拿openstack命令行包一层,或者在 Python 里 import openstacksdk。这两种做法我都试过,各有各的别扭。
包 CLI 的问题在于解析输出。openstack backup create默认给人看,是表格;加-f json才是结构化数据,但错误信息全走 stderr,退出码只有一个笼统的 1,你分不清是配额超了、卷不存在还是 token 过期。更麻烦的是并发——每个卷 fork 一个子进程,几十张卷同时起,Keystone 那边直接被登录风暴打出一片 429。
openstacksdk 的问题反过来,是重。它会拉进 keystoneauth1、oslo.config、oslo.serialization 一整串依赖,小环境里为了跑一个备份脚本装一堆 oslo 包,版本还能跟系统的 python 环境打架。而这个项目真正需要的接口只有六个:认证、列卷、建备份、查备份、列备份、删备份。
所以这里的选型是requests负责 HTTP,PyYAML负责配置,concurrent.futures的ThreadPoolExecutor负责并发,logging负责落日志,全是标准库或者一个依赖就能搞定的东西。token 管理自己写,因为逻辑简单到不值得引入框架:拿一次、记下过期时间、提前五分钟续。常见做法是用 keystoneauth1 的 session 来做自动续期,如果你所在环境的 token 生命周期特别短(比如半小时),换回 keystoneauth1 会更省心。
2.3 目录结构:八个模块各管一段
工程骨架按职责切,不按接口切,这样加功能时改动面小:
cinderback/ ├── cinderback/ │ ├── __init__.py │ ├── cli.py # argparse 子命令:plan / run / cleanup / inventory │ ├── config.py # YAML 读取 + 字段校验 + 默认值填充 │ ├── client.py # Keystone 认证、token 续期、Cinder REST 封装 │ ├── inventory.py # 卷枚举与过滤规则 │ ├── backup.py # 单个卷的备份创建 + 状态轮询 + 重试 │ ├── policy.py # 保留策略:keep_last / keep_days 的取舍 │ ├── cleanup.py # 按策略挑备份、保护 parent、执行删除 │ └── utils.py # 时间解析、名字生成、CSV 写出 ├── conf/ │ └── cinderback.yaml # 唯一一份配置样例 ├── scripts/ │ └── cinderback.cron # 定时任务样例,带 flock 防重叠 ├── tests/ │ └── test_policy.py # 保留策略的纯函数测试,不需要连环境 ├── requirements.txt └── pyproject.tomlpolicy.py被单独拆出来是有原因的:它是全项目唯一一块纯函数逻辑,输入是一组 backup 字典,输出是该删的列表,不碰网络,测试起来几十毫秒跑完。把这种逻辑从网络调用里剥出来,是这类脚本类项目最值钱的一个习惯,靠它我才能在改保留策略的时候不用起一套测试环境。
2.4 装起来,先跑一次 plan 看一眼
不要上来就run。这个工具默认提供plan子命令,只打印不执行,是整套流程里最该先用的东西:
python3 -m venv .venv && source .venv/bin/activate pip install -r requirements.txt cp conf/cinderback.example.yaml conf/cinderback.yaml chmod 600 conf/cinderback.yaml # 里面明文存着服务账号密码 # 只读模式:列出这一轮会备份哪些卷、会删哪些备份 python -m cinderback.cli plan --config conf/cinderback.yaml # 单卷试跑,把风险压到一张卷上 python -m cinderback.cli run --config conf/cinderback.yaml --volume-id 6f1c... --no-cleanupplan会做三件事:认证、拉全量卷、套过滤规则,然后把「命中 / 跳过及原因」打成表。第一次跑最常见的输出是「命中 0 张卷」,原因八成是filter.name_regex写得太死或者项目名对不上,这时候改配置比改代码快得多。--no-cleanup是个安全阀,试跑阶段一定带上,避免第一次执行就把线上历史备份清空。
配置文件长这样,字段不多但每个都有用:
auth: auth_url: "https://keystone.example.internal:5000/v3" username: "backup-svc" password: "change-me" project_name: "ops" user_domain_name: "Default" project_domain_name: "Default" cinder: endpoint: "" # 留空则从 service catalog 自动发现 volumev3 timeout: 30 verify_ssl: true backup: name_prefix: "cb-" incremental: true force: false # 卷 in-use 时是否强制备份,建议显式打开 poll_interval: 10 poll_timeout: 3600 max_workers: 3 retries: 2 filter: name_regex: "^(prod|db)-" exclude_status: ["error", "error_restoring"] only_attached: false retention: keep_last: 7 keep_days: 14 mode: "and" # and=两个条件都满足才删,保守;or=满足其一就删poll_interval和poll_timeout决定轮询节奏,10 秒一次、1 小时超时对几百 GB 的卷是够的;max_workers不是越大越好,后面第 4 章会讲它跟 cinder-backup 服务进程数的关系;retention.mode用and还是or是策略问题,第 4 章细说。
3. 核心链路拆解:从 Keystone 取 token 到 /backups 落一条记录
3.1 配置加载:一份 YAML 管住认证、并发和保留策略
配置模块的第一职责不是读文件,是把「缺字段」这件事在启动阶段就炸出来,而不是等跑了两百张卷之后在轮询里抛 KeyError。项目里用的是 dataclass 加一层手写校验,不引入 pydantic,因为配置项就那么二十来个:
# cinderback/config.py from dataclasses import dataclass, field import yaml @dataclass class BackupCfg: name_prefix: str = "cb-" incremental: bool = True force: bool = False poll_interval: int = 10 poll_timeout: int = 3600 max_workers: int = 3 retries: int = 2 container: str = "" # 只有部分后端需要在建备份时指定容器 def load_config(path): with open(path, encoding="utf-8") as f: raw = yaml.safe_load(f) or {} if "auth" not in raw or "auth_url" not in raw["auth"]: raise ConfigError("auth.auth_url 必填,指向 Keystone v3 端点") cfg = SimpleNamespace( auth=SimpleNamespace(**raw["auth"]), cinder=SimpleNamespace(**raw.get("cinder", {})), backup=BackupCfg(**raw.get("backup", {})), filter=SimpleNamespace(**raw.get("filter", {})), retention=SimpleNamespace(**raw.get("retention", {})), ) if cfg.backup.max_workers < 1: raise ConfigError("backup.max_workers 至少为 1") return cfg这段的关键点在BackupCfg的默认值:所有字段都有默认值,意味着backup:整段可以省略,工具用一套保守参数先把流程跑通,再让运维按需覆盖。auth反过来是强校验,缺auth_url直接抛,因为认证配错了后面所有步骤都是白跑。container默认空字符串而不是None,是为了后面拼 payload 时少一层判断——空就不往请求体里塞这个键。
3.2 会话与认证:token 在响应头里,不在 body 里
这是新手最容易翻的一处。Keystone v3 的/auth/tokens接口,token 本体放在响应头X-Subject-Token,响应 body 里只有token元信息(含过期时间、catalog、project)。照着某些老文档去 body 里找access.token.id,拿到的是 KeyError,翻车现场极其迷惑。
# cinderback/client.py import time import requests class CinderClient: def __init__(self, cfg): self.cfg = cfg self.session = requests.Session() self.session.verify = cfg.cinder.verify_ssl self.session.headers["Accept"] = "application/json" self._token = None self._token_expire = 0 self._base_url = None self._project_id = None def authenticate(self): url = f"{self.cfg.auth.auth_url}/auth/tokens" body = {"auth": { "identity": { "methods": ["password"], "password": {"user": { "name": self.cfg.auth.username, "domain": {"name": self.cfg.auth.user_domain_name}, "password": self.cfg.auth.password, }}, }, "scope": {"project": { "name": self.cfg.auth.project_name, "domain": {"name": self.cfg.auth.project_domain_name}, }}, }} r = self.session.post(url, json=body, timeout=self.cfg.cinder.timeout) r.raise_for_status() self._token = r.headers["X-Subject-Token"] # 关键:token 在响应头 tok = r.json()["token"] self._token_expire = _parse_ts(tok["expires_at"]) self._project_id = tok["project"]["id"] self._base_url = self._pick_endpoint(tok["catalog"]) self.session.headers["X-Auth-Token"] = self._token def _pick_endpoint(self, catalog): if self.cfg.cinder.endpoint: return self.cfg.cinder.endpoint.rstrip("/") for svc in catalog: if svc["type"] in ("volumev3", "volumev2"): for ep in svc["endpoints"]: if ep["interface"] == "public": return ep["url"].rstrip("/") raise ConfigError("service catalog 里找不到 volumev3 端点") def ensure_token(self): # 提前 5 分钟续期,避免一批任务跑到一半集体 401 if not self._token or time.time() > self._token_expire - 300: self.authenticate() def request(self, method, path, **kwargs): self.ensure_token() url = f"{self._base_url}/{path.lstrip('/')}" r = self.session.request(method, url, timeout=self.cfg.cinder.timeout, **kwargs) if r.status_code == 401: # token 被服务端提前判死,重认证一次 self.authenticate() r = self.session.request(method, url, timeout=self.cfg.cinder.timeout, **kwargs) return r_pick_endpoint里优先用 catalog 发现而不是写死 URL,好处是同一个配置能跟随后端迁移走;ensure_token的 300 秒提前量是血泪经验,token 默认有效期一小时,一批卷跑四十分钟很常见,卡在最后几张卷上集体失败最难查。request里的 401 重认证只重试一次,防止密码本身错了导致死循环。
还有一个坑藏在scope里:必须带project作用域,否则拿到的 token 没有绑定项目,Cinder 的/v3/{project_id}/backups路径里那个 project_id 就没处取,或者取到的是用户默认项目,最后备份全落到错误的租户下。
3.3 卷筛选:把「该备份的卷」从全量里挑出来
全量卷枚举本身很简单,GET /volumes/detail,但要注意分页和权限。普通用户只能看到自己项目的卷,管理员账号加all_tenants=1才能跨项目拉全量,而这个参数在 Cinder 里叫all_tenants而不是别的名字,写错了不报错,只是静默少返回数据,属于典型的黑匣子行为。
# cinderback/inventory.py import re def list_volumes(client, cfg, all_tenants=False): volumes, marker = [], None while True: params = {"limit": 200} if all_tenants: params["all_tenants"] = 1 if marker: params["marker"] = marker r = client.request("GET", f"/volumes/detail", params=params) r.raise_for_status() page = r.json()["volumes"] volumes.extend(page) if len(page) < 200: # 最后一页 break marker = page[-1]["id"] return volumes def select_volumes(volumes, cfg): pattern = re.compile(cfg.filter.name_regex) if cfg.filter.name_regex else None picked, skipped = [], [] for v in volumes: if pattern and not pattern.search(v.get("name") or ""): skipped.append((v, "name 不匹配")) elif v["status"] in cfg.filter.exclude_status: skipped.append((v, f"状态为 {v['status']}")) elif cfg.filter.only_attached and not v.get("attachments"): skipped.append((v, "未挂载")) else: picked.append(v) return picked, skipped分页用limit=200加 marker 游标,是 Cinder API 的标准做法,返回条数小于 limit 就说明到底了。select_volumes里刻意返回skipped列表,每条带上跳过原因,plan子命令就是把这两个列表打出来——运维看到「跳过 87 张,原因:name 不匹配」,立刻就知道是正则写错了,不用猜。过滤顺序也有讲究:先按名字,再按状态,最后按挂载状态,因为名字正则是最便宜的判断,挂在前面能省掉后面所有逻辑。
3.4 创建备份:payload 里三个容易写错的字段
真正发请求的代码不长,但每个字段都有脾气:
# cinderback/backup.py def create_backup(client, cfg, volume, project_id): name = f"{cfg.backup.name_prefix}{volume['name'] or volume['id'][:8]}-{_stamp()}" payload = {"backup": { "volume_id": volume["id"], "name": name[:255], # Cinder 对 name 有长度上限 "description": "cinderback auto", "incremental": cfg.backup.incremental, "force": cfg.backup.force, }} if cfg.backup.container: payload["backup"]["container"] = cfg.backup.container r = client.request("POST", f"/{project_id}/backups", json=payload) if r.status_code == 202: return r.json()["backup"] if r.status_code == 413: raise BackupFailed(f"配额不足: {r.text}") raise BackupFailed(f"创建失败 {r.status_code}: {r.text}")第一个坑是force。卷处于in-use状态时,Cinder 默认拒绝备份,必须显式传force: true;但这个参数在部分后端上并不生效,照样返回 400,所以配置里给了开关而不是写死。第二个坑是container,只有把备份落到对象存储类后端时才需要指定容器名,不指定会落到默认容器里,之后清理脚本按容器过滤就找不到了。第三个是返回码:成功返回的是 202 不是 201,拿 201 判断会永远认为失败。
name截断到 255 字符看着是小事,但卷名本身就长、再加前缀和时间戳,很容易超,Cinder 那边的行为是直接报错而不是截断,属于典型的「本地测不到、线上一跑就炸」。
4. 备份编排与保留策略:轮询、并发限流和过期清理怎么配合
4.1 状态轮询:creating 到 available 之间发生了什么
建备份是异步的,POST 返回 202 只是把任务丢进了队列,真正的数据拷贝由 cinder-backup 服务在后台做。所以必须有轮询,而轮询写不好会引出两个问题:太频繁打爆 API,太粗放导致长尾任务被误判超时。
# cinderback/backup.py import time def wait_backup(client, cfg, backup_id, project_id): deadline = time.monotonic() + cfg.backup.poll_timeout last = None while time.monotonic() < deadline: r = client.request("GET", f"/{project_id}/backups/{backup_id}") r.raise_for_status() b = r.json()["backup"] if b["status"] != last: log.info("backup %s %s -> %s", backup_id, b["name"], b["status"]) last = b["status"] if b["status"] == "available": return b if b["status"] in ("error", "error_restoring"): raise BackupFailed(b.get("fail_reason") or "unknown") time.sleep(cfg.backup.poll_interval) raise BackupTimeout(f"备份 {backup_id} 超过 {cfg.backup.poll_timeout}s 未完成")只在状态变化时打日志,是因为一个 500GB 的卷可能轮询几百次,每次都打一行日志会把日志文件刷爆,真出事时反而找不到关键行。fail_reason字段不是每个版本都有,用.get()兜底。用time.monotonic()而不是time.time(),是为了防止宿主机 NTP 校时导致超时判断跳变——这个坑我在别的定时任务上踩过一次,整个批次集体判定超时,排查了半天才发现是时钟回拨。
4.2 并发与限流:为什么默认只开 3 个线程
直觉上几十张卷应该开几十个线程一起跑,实际上不行。cinder-backup 是一个独立服务,它内部有自己的处理队列,并发上限由服务启动时的进程数决定(常见部署是 4 到 8 个)。你从客户端开 50 个线程,多出来的请求不会变快,只会在服务端队列里排队,然后客户端的poll_timeout先到,误判成超时,接着触发重试——重试又建了一批新备份,把队列堵得更死,最后真的是雪崩。
# cinderback/backup.py from concurrent.futures import ThreadPoolExecutor, as_completed def run_batch(client, cfg, volumes, project_id): results = [] with ThreadPoolExecutor(max_workers=cfg.backup.max_workers) as pool: futures = { pool.submit(backup_one, client, cfg, v, project_id): v for v in volumes } for fut in as_completed(futures): vol = futures[fut] try: results.append({"volume": vol["id"], "ok": True, "backup": fut.result()}) except Exception as e: # 单卷失败不影响整批 log.error("volume %s 备份失败: %s", vol["id"], e) results.append({"volume": vol["id"], "ok": False, "err": str(e)}) return resultsmax_workers默认 3 是个保守值,判断依据就是 cinder-backup 的进程数减一,留一个位置给正常的人工操作。as_completed而不是按提交顺序取结果,是为了让快的先返回,整体墙钟时间更短。单卷异常在这里被吃掉并记进结果列表,不往上抛——批处理里最忌讳一个卷失败把整批中断,剩下九十张卷还没开始跑就退出了。
4.3 保留策略:按份数还是按天数
保留策略这块最容易出事,因为它删的是数据。项目里的做法是「两个条件同时满足才删」:
# cinderback/policy.py from datetime import datetime, timezone def select_expired(backups, cfg, now=None): now = now or datetime.now(timezone.utc) ordered = sorted(backups, key=lambda b: _parse_ts(b["created_at"]), reverse=True) expired = [] for idx, b in enumerate(ordered): if b["status"] not in ("available", "error"): continue # creating/restoring 的一律不碰 too_many = idx >= cfg.retention.keep_last too_old = (now - _parse_ts(b["created_at"])).days > cfg.retention.keep_days hit = (too_many and too_old) if cfg.retention.mode == "and" else (too_many or too_old) if hit: expired.append(b) return expiredkeep_last=7、keep_days=14、mode=and这个组合的含义是:既要排在第 8 位之后,又要超过 14 天,才删。效果是「最近 7 份一定保住,同时 14 天内的也一定保住」,两个维度互相兜底。如果改成or,语义就变成「超过 7 份或超过 14 天就删」,磁盘压力小但风险高,适合备份量特别大、存储吃紧的环境。
排序用created_at而不是id,因为created_at才是业务时间;status过滤必须放在最前面,删掉一个正在 creating 的备份会导致 Cinder 侧状态机错乱,那真是后悔药都没地方买。
4.4 清单导出与幂等重跑
每批跑完导一份清单,字段固定:卷 ID、卷名、备份 ID、状态、大小、耗时、失败原因。JSON 给程序读,CSV 给人看。这件事的价值在半年后才体现——当有人问「三月十七号那张 db-02 到底备没备」,翻清单比翻日志快一百倍。
幂等性是另一条设计线。同一个卷同一小时内被触发两次(cron 重叠、手工补跑),不应该产生两份备份。项目里的做法是跑之前先拉一次该卷当天的备份列表,如果已经有available且名字前缀匹配的记录,直接跳过:
def already_done_today(client, cfg, volume, project_id, today): r = client.request("GET", f"/{project_id}/backups/detail", params={"volume_id": volume["id"], "limit": 100}) for b in r.json()["backups"]: if (b["status"] == "available" and b["name"].startswith(cfg.backup.name_prefix) and _parse_ts(b["created_at"]).date() == today): return b return None这个判断有成本(每卷多一次 GET),但换来的是「cron 和手工补跑撞车也不出事」。定时脚本不加flock的话重叠执行太常见了,加锁是第一道防线,幂等判断是第二道。
5. 避坑与排查:Cinder 备份最容易翻车的五个地方
5.1 备份一直卡在 creating,一小时后超时
现象:plan里命中正常,run也返回了 202,但轮询日志一直打creating,最后抛BackupTimeout。
原因通常不在脚本,而在服务端。三个高发点:cinder-backup 服务本身挂了或没注册;备份后端(NFS 挂载点、对象存储桶)不可达;卷特别大、传输时间超过你配的poll_timeout。
解决顺序:先查服务状态cinder service-list | grep backup,看对应节点的 State 是不是 up;再翻/var/log/cinder/backup.log,NFS 类后端最典型的报错是挂载点失联;确认服务没问题就把poll_timeout从 3600 调到 7200 再看一轮。不要在这种情况下直接加retries,重试会再建一份备份,队列更堵。
5.2 跑到一半集体 401,前面成功后面全挂
现象:前二十张卷备份成功,之后每一张都报 401,日志里一片Unauthorized。
原因:token 过期。Keystone 默认 token 有效期一小时,一批卷如果每张都要跑十几分钟,很容易跨过这个边界。脚本如果只在启动时认证一次,后面全部失效。
解决:ensure_token里提前 5 分钟续期,request里对 401 做一次重认证重试。另外要留意服务账号密码被改过、或者项目被迁移导致 scope 无效的情况,这类问题表现一样但重试也没用,日志里会连续出现两次 401,一眼能分辨。
5.3 增量备份越备越慢,最后直接失败
现象:开了incremental: true,前面几天正常,某天开始单个卷备份时间翻倍,再过几天直接报错。
原因:增量备份依赖父备份(parent_id),链条上任何一环被删掉,后续增量就失去了基准。如果保留策略只看总量、不看依赖关系,很容易把还在被依赖的父备份删掉。
解决:清理逻辑里加一条硬约束——凡是还有子备份引用它的(parent_id指向它),一律跳过。或者在配置里干脆关掉增量,全量备份换取逻辑简单。不同 Cinder 后端对增量的支持程度差别很大,上增量之前先在测试环境确认后端支持,别在线上试。
5.4 并发开太大,服务端队列堆积反而更慢
现象:把max_workers从 3 调到 20,期望加速,结果整体耗时反而变长,失败率上升。
原因:cinder-backup 的处理能力是固定的,客户端并发只影响排队位置,不影响处理速度。更要紧的是 Cinder 的 API 层有 rate limit,短时间大量 POST 会收到 413 或 429,脚本这边如果当成普通失败处理,就会触发重试,形成正反馈。
解决:max_workers不超过 cinder-backup 服务进程数,通常 3 到 6。收到 429 时不要立刻重试,退避几十秒;收到 413 直接判定配额不足,重试毫无意义,应该记进清单等人工处理。
5.5 清理脚本误删了手工建的备份
现象:某天同事说他自己手工建的一个备份没了。
原因:select_expired拿的是该卷的全量备份列表,没有区分「谁建的」。name_prefix这个配置项就是为了解决这个问题——但前提是清理逻辑真的用了它。
解决:清理前先按name_prefix过滤,只处理自己建的;再按时间排序取前 N 个。还有个隐蔽的坑是时间解析,Cinder 返回的created_at带Z后缀或者微秒精度,用datetime.strptime配固定格式字符串会解析失败,稳妥做法是用fromisoformat前先把Z替换成+00:00,并统一转成带时区的 datetime 再比较。
6. 二次开发:把它改成会报警、能出指标的备份守卫
用顺手之后最常见的改造诉求是「别再让我靠翻日志发现问题」。最小改动是在run_batch的出口加一个钩子,把结果汇总成一条消息推出去,同时写一份 Prometheus 的 textfile 指标:
# cinderback/report.py def emit(results, textfile="/var/lib/node_exporter/textfile/cinderback.prom"): ok = sum(1 for r in results if r["ok"]) fail = len(results) - ok lines = [ "# HELP cinderback_backup_total 本轮备份尝试数", "# TYPE cinderback_backup_total gauge", f"cinderback_backup_total {len(results)}", f'cinderback_backup_failed_total {fail}', f"cinderback_last_run_timestamp {int(time.time())}", ] with open(textfile + ".tmp", "w") as f: # 先写临时文件再原子替换 f.write("\n".join(lines) + "\n") os.replace(textfile + ".tmp", textfile) if fail: notify(f"cinderback: {fail}/{len(results)} 张卷备份失败", results)原子替换那一步不能省,node_exporter 是定时抓取的,抓到写了一半的文件会解析出错,指标直接断点。notify走 HTTP webhook 就行,别在脚本里塞复杂的告警路由逻辑,那是监控平台该干的事。
还有几个值得做的改造方向,按投入产出比排:把保留策略做成可插拔的Policy类,新环境直接继承覆盖;把filter从单一正则扩展成「正则 + 元数据标签匹配」,方便按业务分组备份;支持多 region 循环执行,一套配置里写多个 auth 块。别急着做 GUI 和数据库,这个工具的定位就是「一台机器上一份配置、cron 定时跑」,一旦引入状态存储,运维成本立刻上一个台阶。
验证改造是否可靠,我的做法是三步:先在tests/里补纯函数用例(policy.py的所有分支都能覆盖,不需要连环境);再用plan对着真实环境跑一遍,确认命中的卷数和人工数出来的一致;最后挑一张最不重要的卷单跑run --volume-id,确认备份真的到了available、指标文件真的写出来了。这三步走完再上 cron,中间任何一步跳过,出问题都要多花几倍时间排查。
从那以后我每次给备份类脚本加新功能,都强制走一遍「plan 看命中、单卷试跑、再放开批量」这三步,哪怕改动只有一行——备份工具坏掉的代价不是报错,是静默地什么都没做,而你以为它在做。希望帮到你。
本文还有配套的精品资源,点击获取