
把 Vault CE 原地换成 OpenBao官方迁移指南里真正会咬人的三处差异OpenBao 是 HashiCorp Vault 的开源分叉现在是 LF Projects 旗下的 OpenSSF Sandbox 项目当前文档版本2.7.x2.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.1OSSOpenBao 版本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: pathaws/ errorplugin not found in the catalog: aws [WARN] core: skipping plugin-based mount entry: pathaws/而bao secrets list依然会列出这个路径❗读它返回No value found—— 官方原文“the same answer an unused path gives”第 4 条是最坑的地方路径在、读不到和路径本来就没用过给的是同一个回答。运维第一反应会是数据没迁过来 / 存储不兼容然后去折腾 raft 快照——而真实原因只是插件缺失。处置确认插件确实不用了之后清理空壳bao secrets disablepathbao auth disablepath差异 2Token 格式变了格式Vault{hvs,hvb,hvr}.long_randomOpenBao[sbr].random官方说法旧 token 仍按其 TTL 被接受但新签发的 token 是 Bao 形式。所以这不是 OpenBao 侧的破坏性变更但它可能打破消费方对 token 格式的假设—— 任何做过前缀校验、正则匹配、或者把 token 写进日志脱敏规则的地方都要在迁移前过一遍。差异 3disable_mlock必须删掉“If you havedisable_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()):tm[type].lower()ifcatalogisnotNone:risktnotincatalog basis目标实例目录else:risktnotinassumed_ok basis兜底清单未核实rows.append({path:path,type:t,risk:risk,basis:basis})returnrows用法# 1) 从 Vault 导出两份挂载清单vault secrets list-formatjsonsecrets.json vault auth list-formatjsonauth.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 用不同的存储路径节点加入集群时会自己从集群拉数据不需要复用旧数据而且回滚更容易先记下哪个节点是 leadervault 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) 确认它成为 voterVoter 字段为 truebao operator raft list-peers最后升级 leader# 先让它主动让位VAULT_ADDRhttps://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 CEhttps://openbao.org/docs/guides/migration/OpenBao 官方文档首页版本线 2.7.x / 2.6.x / 2.5.xOpenSSF Sandbox 项目说明https://openbao.org/docs/OpenBao 项目主页https://github.com/openbao/openbaoOpenSSF 项目页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]:djson.loads(path.read_text(encodingutf-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:int30)-set[str]:urladdr.rstrip(/)/v1/sys/plugins/catalogrequrllib.request.Request(url,headers{X-Vault-Token:token})withurllib.request.urlopen(req,timeouttimeout)asr:djson.loads(r.read().decode())namesset()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]:djson.loads(path.read_text(encodingutf-8))ifisinstance(d,list):return{str(x).lower()forxind}forkeyin(builtin,builtins,catalog,names,detailed):ifkeyind:vd[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()):tm[type].lower()ifcatalogisnotNone:risk,basistnotincatalog,目标实例目录else:risk,basistnotinassumed_ok,兜底清单未核实rows.append({path:path,type:t,risk:risk,basis:basis,plugin_version:m[plugin_version]})returnrowsdefmain()-int:apargparse.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,actionstore_true)aap.parse_args()ifa.self_test:returnself_test()ifnota.secretsandnota.auth:print(至少给一个 --secrets 或 --auth)return2catalogNoneifa.catalog:catalogload_catalog_file(Path(a.catalog))print(f目录来源本地文件{a.catalog}{len(catalog)}项)elifa.bao_addranda.token:try:catalogfetch_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:continuerowsaudit(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]elseOK:8}{r[basis]})all_rowsrows 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 全部升完再动 leaderleader 已step-down并确认新 leader 选出迁移后清理空壳挂载点核查消费方的 token 格式假设