把 Vault CE 原地换成 OpenBao:官方迁移指南里真正会咬人的三处差异
OpenBao 是 HashiCorp Vault 的开源分叉,现在是 LF Projects 旗下的 OpenSSF Sandbox 项目,当前文档版本2.7.x(2.6.x / 2.5.x 仍在维护)。它的官方迁移指南标题就叫**「In-Place Migration from Vault CE」**——原地替换,节点上把 Vault 进程换成 OpenBao,所有配置、端点、URL 都不变。
官方对兼容性的原话是:“OpenBao API should be compatible with Vault to the extent that existing clients should not even register a difference.”(现有客户端甚至不该察觉差异。)只补一句 “Some clients/service might have to be restarted.”
但指南里同时写着一条很容易被跳过的警告,它是本文的重点:能不能原地换成功,取决于你的存储里有什么,而不是取决于 Vault 的版本。
本文按官方指南把迁移路径、约束边界、以及三处真会咬人的差异讲清楚,并给一份迁移前盘点脚本(可离线自测,附自测输出)。
一、先划清官方测试过的边界
官方指南给的前提条件写得很窄,照抄如下:
| 项目 | 官方测试过的值 |
|---|---|
| Vault 版本 | 1.14.1(OSS) |
| OpenBao 版本 | 2.2.0 |
| 存储后端 | Raft |
| 解封方式 | Shamir unseal(不支持自动解封) |
| 版本范围 | 只测过社区版 v1.14.x,没测 Vault Enterprise |
并且明确声明:
“This guide was only tested against Vault 1.14.1. Newer versions are untested and unsupported:OpenBao makes no guarantees about storage compatibility with Vault.Use at your own risk.”
这句话是整个迁移决策的关键。它不是说"不能用",是说"存储兼容性没有任何保证"—— 所以必须有回滚方案,不能只有一条单程路。
两条前置动作:
- Vault 低于 1.14.1 的,先升到 1.14.1。
- Vault 是用 1.3 以前的版本初始化的,可能需要 rekey—— Vault 1.3 之前用的是另一套 Shamir seal 实现,那套实现已从 OpenBao 中移除。
二、三处真会咬人的差异
差异 1:插件 —— 最容易误判成"数据没迁过来"
官方指南里排在第一位的警告不是版本、不是存储,而是插件:
“Whether an in-place swap works at all depends less on the Vault version than on what your store contains, in particular on mounts whose plugin OpenBao does not ship.”
OpenBao 默认不带很多插件。后果非常隐蔽:
- 启动时,插件不在目录里的挂载点会被跳过初始化
- 服务端日志里有痕迹,但只有这两行:
[ERROR] core: failed to create mount entry: path=aws/ error="plugin not found in the catalog: aws" [WARN] core: skipping plugin-based mount entry: path=aws/ - 而
bao secrets list依然会列出这个路径 - ❗读它返回
No value found—— 官方原文:“the same answer an unused path gives”
第 4 条是最坑的地方:"路径在、读不到"和"路径本来就没用过"给的是同一个回答。运维第一反应会是"数据没迁过来 / 存储不兼容",然后去折腾 raft 快照——而真实原因只是插件缺失。
处置:确认插件确实不用了之后,清理空壳:
bao secrets disable<path>bao auth disable<path>差异 2:Token 格式变了
| 格式 | |
|---|---|
| Vault | {hvs,hvb,hvr}.<long_random> |
| OpenBao | [sbr].<random> |
官方说法:旧 token 仍按其 TTL 被接受;但新签发的 token 是 Bao 形式。
所以这不是 OpenBao 侧的破坏性变更,但它可能打破消费方对 token 格式的假设—— 任何做过前缀校验、正则匹配、或者把 token 写进日志脱敏规则的地方,都要在迁移前过一遍。
差异 3:disable_mlock必须删掉
“If you have
disable_mlockin your Vault config, remove it. OpenBao does not usemlocksince 2.0.0.”
这条属于"不改会起不来"的类型,但很容易漏——因为它在配置文件里,不在迁移步骤清单里。
另外一处容易漏的:如果你用了 Vault 的audit file后端,要保证 OpenBao 进程能写那个文件。通常是这样一句,但必须在停掉该节点的 Vault 之后再执行:
chownopenbao:openbao vault_audit_log.log三、迁移前盘点脚本
上面三处差异里,只有第一处需要先做功课——而它恰好也是唯一会造成"看起来像数据丢失"的。所以这一步不该靠迁移后排查,应该迁移前跑完。
思路:不要硬编码"OpenBao 内置哪些插件"(那会随版本变,写死就是错的),而是从目标 OpenBao 实例拉真实目录去比对;拿不到实例时退回一份兜底清单,并在结论里明确标注"未核实"。
# vault_migration_audit.py(节选,完整脚本见文末)defaudit(mounts,catalog,assumed_ok):rows=[]forpath,minsorted(mounts.items()):t=m["type"].lower()ifcatalogisnotNone:risk=tnotincatalog basis="目标实例目录"else:risk=tnotinassumed_ok basis="兜底清单(未核实)"rows.append({"path":path,"type":t,"risk":risk,"basis":basis})returnrows用法:
# 1) 从 Vault 导出两份挂载清单vault secrets list-format=json>secrets.json vault auth list-format=json>auth.json# 2) 与目标 OpenBao 的真实插件目录比对python vault_migration_audit.py--secretssecrets.json--authauth.json\--bao-addr https://bao.example.com:8200--token"$BAO_TOKEN"自测输出(脚本内置 fixture,不依赖网络与实例):
============================================================================== vault_migration_audit 自测 ============================================================================== [PASS] 解析出 5 个挂载点 —— 5 [PASS] 只把目录里没有的类型标为风险 —— ['acme-corp/'] [PASS] 有目录时依据标注为「目标实例目录」 [PASS] 无目录时依据标注为「兜底清单(未核实)」 [PASS] 兜底清单下 kv 不报风险 ============================================================================== ✅ 全部通过 —— 判定逻辑正确,且**有/无目录两种情况都明确标注依据**最后一条断言是刻意加的:如果拿不到真实目录,脚本必须自己说出来"这个结论未核实",不能让人拿着兜底清单的结论去拍板。这是这类"盘点型脚本"最容易骗人的地方。
四、升级顺序:follower 先行,leader 最后
官方流程的核心是先动 follower,再动 leader,全程一次一个节点。
准备
- 备份:raft 快照,或文件系统原子快照(ZFS / BTRFS)
- 装好 OpenBao 并配好,但不要启动
- 建议给 OpenBao 用不同的存储路径:节点加入集群时会自己从集群拉数据,不需要复用旧数据;而且回滚更容易
先记下哪个节点是 leader
vault status# 看 Active Node Address# 或curlhttps://vault.example.com:8200/v1/sys/leader逐个升级 follower(每个节点重复 5 步)
# 1) 停 Vault# 2) 起 OpenBao# 3) 加入集群bao operator raftjoinvault-03.example.com:8200# 4) 解封bao operator unseal# 5) 确认它成为 voter(Voter 字段为 true)bao operator raft list-peers最后升级 leader
# 先让它主动让位VAULT_ADDR=https://vault-03.example.com:8200 vault operator step-down# 确认新 leader 已选出vault status# 然后对原 leader 重复上面 1–5 步,只是第 3 步换成当前 leader 地址五、结论
把这次迁移的风险排个序,和直觉恰好相反:
| 你以为的风险 | 实际的风险 |
|---|---|
| Vault 版本不够新 | 官方只测过1.14.1,版本不是主要变量 |
| 存储不兼容 | 官方明确说不提供保证—— 所以必须准备回滚,而不是准备"它一定能成" |
| 插件缺失 | ⚠️真正的头号风险,且症状(No value found)会被误判成数据丢失 |
一句话行动项:迁移前先跑盘点,把"会变成空壳"的挂载点找出来;迁移时先 follower 后 leader;迁移后清理空壳。至于存储兼容性——官方不保证,那就用不同的存储路径 + 保留旧数据,给自己留一条退路。
参考来源
- OpenBao 官方文档 · In-Place Migration from Vault CE:https://openbao.org/docs/guides/migration/
- OpenBao 官方文档首页(版本线 2.7.x / 2.6.x / 2.5.x,OpenSSF Sandbox 项目说明):https://openbao.org/docs/
- OpenBao 项目主页:https://github.com/openbao/openbao
- OpenSSF 项目页:https://openssf.org/projects/openbao/
附:完整脚本
#!/usr/bin/env python# -*- coding: utf-8 -*-"""Vault CE → OpenBao 迁移前盘点:找出「迁过去会变成空壳」的挂载点。"""from__future__importannotationsimportargparseimportjsonimportsysimporturllib.requestfrompathlibimportPath FALLBACK_KNOWN={"kv","cubbyhole","generic","transit","pki","ssh","totp","ldap","database","rabbitmq","aws","azure","gcp","alicloud","consul","nomad","terraform","ad","okta","userpass","approle","cert","token","github","radius","kubernetes","jwt","oidc",}defload_mounts(path:Path)->dict[str,dict]:d=json.loads(path.read_text(encoding="utf-8"))out={}fork,vind.items():ifnotisinstance(v,dict):continueout[k]={"type":v.get("type","?"),"plugin_version":v.get("plugin_version",""),"options":v.get("options")or{},"external":bool(v.get("plugin_version"))}returnoutdeffetch_catalog(addr:str,token:str,timeout:int=30)->set[str]:url=addr.rstrip("/")+"/v1/sys/plugins/catalog"req=urllib.request.Request(url,headers={"X-Vault-Token":token})withurllib.request.urlopen(req,timeout=timeout)asr:d=json.loads(r.read().decode())names=set()foritemin(d.get("data",{})or{}).get("detailed",[])or[]:forkin("name","type"):ifitem.get(k):names.add(str(item[k]).lower())returnnamesdefload_catalog_file(path:Path)->set[str]:d=json.loads(path.read_text(encoding="utf-8"))ifisinstance(d,list):return{str(x).lower()forxind}forkeyin("builtin","builtins","catalog","names","detailed"):ifkeyind:v=d[key]ifisinstance(v,list):return{str(xifnotisinstance(x,dict)elsex.get("name","")).lower()forxinv}-{""}raiseSystemExit(f"读不出目录:{path}的形状不认识")defaudit(mounts,catalog,assumed_ok):rows=[]forpath,minsorted(mounts.items()):t=m["type"].lower()ifcatalogisnotNone:risk,basis=tnotincatalog,"目标实例目录"else:risk,basis=tnotinassumed_ok,"兜底清单(未核实)"rows.append({"path":path,"type":t,"risk":risk,"basis":basis,"plugin_version":m["plugin_version"]})returnrowsdefmain()->int:ap=argparse.ArgumentParser()ap.add_argument("--secrets")ap.add_argument("--auth")ap.add_argument("--bao-addr",default="")ap.add_argument("--token",default="")ap.add_argument("--catalog")ap.add_argument("--self-test",action="store_true")a=ap.parse_args()ifa.self_test:returnself_test()ifnota.secretsandnota.auth:print("至少给一个 --secrets 或 --auth")return2catalog=Noneifa.catalog:catalog=load_catalog_file(Path(a.catalog))print(f"目录来源:本地文件{a.catalog}({len(catalog)}项)")elifa.bao_addranda.token:try:catalog=fetch_catalog(a.bao_addr,a.token)print(f"目录来源:目标实例{a.bao_addr}({len(catalog)}项)")exceptExceptionasexc:print(f"!! 拉目标实例目录失败:{type(exc).__name__}:{exc}")print(" 退化为兜底清单 —— 结论**未核实**,请勿据此拍板")else:print("未给目录来源:退化为兜底清单,结论**未核实**")all_rows=[]forlabel,pin(("secrets",a.secrets),("auth",a.auth)):ifnotp:continuerows=audit(load_mounts(Path(p)),catalog,FALLBACK_KNOWN)print(f"\n=={label}:{len(rows)}个挂载点 ==")forrinrows:print(f"{r['path']:<34}{r['type']:<16}"f"{'⚠️ 风险'ifr['risk']else'OK':<8}{r['basis']}")all_rows+=rows risky=[rforrinall_rowsifr["risk"]]print(f"\n共盘点{len(all_rows)}个挂载点,高风险{len(risky)}个")forrinrisky:print(f" ⚠️{r['path']}类型={r['type']}{r['basis']}")return1ifriskyelse0环境:Python 3.10+,只用标准库,可用--self-test离线验证判定逻辑。
附:迁移检查单
- Vault 已升到1.14.1(低于此版本先升)
- 确认 Vault 不是用1.3 以前的版本初始化的(否则可能要 rekey)
- 跑完插件盘点,确认每个风险挂载点的处置方式
- raft 快照 / 文件系统原子快照已做
- OpenBao 已安装配置、未启动
- OpenBao 使用不同的存储路径
- 配置文件里的
disable_mlock已删除 audit file后端的文件权限已处理(在停掉 Vault 之后)- follower 全部升完,再动 leader
- leader 已
step-down并确认新 leader 选出 - 迁移后清理空壳挂载点
- 核查消费方的 token 格式假设