6 月初完成了一次规模迁移:把老 Docker Swarm 平台的全部用户搬到新 Kubernetes 集群。574 个用户、124G 数据量、573 个中转站密钥需要转换。数据完整保留,管理组件在新系统全新部署。
这不是"先设计再执行"的故事。而是在两个覆盖不同数据形态的真实用户身上走通全流程,每踩到一个坑就固化成 runbook 里的一条,再展开到全量。最终沉淀下来七个必须遵守的关键点。
为什么选择循序渐进而非一步到位
迁移之前我做了一个完整的架构设计:怎么打包数据、用什么协议传输、新系统怎么导入。理论上没有漏洞。
但在动手准备脚本时,我决定先拿两个真实用户验证。不是因为不放心设计,而是因为数据迁移这类操作,细节决定成败。方案在脑子里再完美,一旦接触现实数据就会暴露出盲点。
我选了一个 volume 型用户和一个 bind 型用户。两种挂载方式代表了数据存储的完全不同逻辑。如果两个都能通,全量迁移的风险就能显著降低。
七个从测试用户踩出来的关键点
1. 数据双源:bind 和 volume 走不同的导出路径
容器数据有两种存储方式。bind 挂载直接把主机目录映射进容器,volume 是由容器引擎管理的抽象存储。两者在容器里看起来一样,但取数据的方式完全不同。
bind 型用户的数据在主机上就是普通目录。我最初想直接用 tar 打包,脚本很简单:
tar czf user.tgz <user-data-dir>/
结果一个 50GB 的目录,打出来的包里只有 13 个文件。其他数千个文件都消失了。
排查过程:先检查源目录确实有完整的文件。再看 tar 命令的权限——发现文件的所有者是 UID 10001(容器内的非 root 用户)。当 tar 以普通用户身份运行时,对某些文件没有读权限。容器内的应用可以读,是因为它就是 UID 10001 的进程。
解决方案就一个:用 sudo 运行 tar。
sudo tar czf user.tgz <user-data-dir>/
这次拿到了完整的数据。但 sudo 意味着脚本需要配置 sudoers,或者在迁移期间给执行用户 sudo 权限。这是一个权限模型的变化。
volume 型用户的情况不同。volume 数据不在主机上直接可见,而是由容器引擎管理。要取出数据必须进到容器内部。我的做法是启动一个 alpine 容器,挂载目标 volume,然后在容器内运行 tar 打包。
docker run --rm -v <volume-name>:/data alpine tar czf - /data | tee user.tgz
容器内的进程对 volume 有完整的读权限,不存在属主问题。打出来的包是完整的。
这两条路的存在意味着迁移脚本必须先判断用户数据的挂载类型。我在数据库里加了一个字段记录每个用户是 bind 还是 volume,导出脚本根据这个字段选择对应的打包方式。
2. 设备表如果不迁,桌面端会完全失效
中间有一个 table 我差点漏掉。老系统数据库里有一张 ConnectorDevice 表,记录的是每个用户绑定的设备信息。一共 658 个设备记录跨 551 个用户。
初版迁移计划里没有这张表。理由是"设备信息不是核心用户数据,新系统支持重新绑定"。
验证第一个 volume 型用户时一切正常——账号能登,数据在那。第二个 bind 型用户登上去以后,试着从桌面端的工具栏打开一个功能,结果无响应。
日志显示工具调用失败。错误消息指向设备 token 查询。追进去才发现,工具栏里的每个按钮都通过设备标识符来路由调用,设备表里没有这个用户,路由直接返回 404。
这不是"用户可以重新绑定"就解决的问题。用户从桌面端发起的操作链路已经依赖于设备标识,设备表是必需的。漏掉它等于功能瘫痪。
所以 ConnectorDevice 表成了迁移的强制条件。新系统的数据库导入流程里,要同时导入这张表,并保持 userId 的一致性。
3. 中转站账号不变,但密文必须转换
两个平台都接入同一个 sub2api 中转站。中转站管理着用户的各种第三方工具授权。在老系统里,这些授权用老平台的 SEALED_BOX 密钥加密存储。搬到新系统,需要用新平台的 SEALED_BOX 密钥重新加密。
为什么要转换?SEALED_BOX 是一种公钥加密方案,每个平台都有自己的密钥对。老平台的私钥无法解密新平台加的密文,所以迁移过程必须:
- 用老平台的私钥解密
- 取出明文
- 用新平台的公钥重新加密
我写了一个转换脚本,用 libsodium 的 crypto_box_seal 接口来验证整个往返过程。解出来的 keyLen=67、pwLen=32,用新平台的私钥解密验证通过。
这个转换必须在导出端完成。新系统导入时拿到的是已经用新密钥加密的密文,导入脚本直接写进数据库,中转站调用也会成功。
4. userId 保持不变,clusterId 需要改
用户的全局标识是 userId。在老系统里它是用户的数据库主键,在新系统也是。所有的关联记录——设备表、配额表、中转站账号——都通过 userId 串联。
userId 必须 1:1 迁移,不能改。
clusterId 是集群标识。老系统里 clusterId 反映的是容器运行在哪个 Swarm 集群。新系统采用 Kubernetes,集群标识体系不同。我统一把新系统的 clusterId 改成 cce-1。这个值对应新 K8s 集群的内部标识。
新系统的容器启动脚本会读这个字段,根据它去连接对应的控制平面。改错 clusterId 等于把容器指向了错误的集群。
5. 用私有桶中转数据,不走公开 CDN
打包好的数据文件动辄几百 MB 到几 GB。老系统的网络和新系统的网络不在同一个 VPC 里,直接 scp 传输会占用宝贵的跨域带宽。
我用云对象存储的私有桶作为中间仓库。老系统打好包以后上传到私有桶,新构建机再从私有桶下载。两端都是到公有云厂商的接入点,利用云厂商内部的高速专线。
关键是这个桶必须是私有的,不挂公开 CDN。用户数据严禁走任何公开网络。每次上传和下载都用签名的 URL 来授权,传完数据立刻删除对象,不留痕迹。
我验证过 md5:一个 13.7MB 的包从老机上传、新机下载,字节级一致。解包出来的 4778 个文件和 openclaw.json 的校验和都对。
6. SFS 挂载会掉,检查后自动重新 mount
新系统把共享存储(SFS)挂到一台 build 机上。这台机器负责接收下载的数据、解包、导入数据库。
问题是每次 build 机重启,SFS 的挂载点就掉了。虽然自动挂载配置写在 fstab 里,但在容器平台的场景下不总是可靠。如果导入脚本在挂载掉的时刻运行,直接写会失败。
解决方案是每个导入脚本的开头加一个检查逻辑:
if ! mountpoint /mnt/sfs > /dev/null 2>&1; then
mount -a
fi
脚本先看一遍挂载点是否存活,不活就执行 mount -a 重新挂载。这样即使 build 机在迁移过程中重启了,脚本也能自动恢复。
7. 迁移完成后桌面端 Connector 必须重启
新系统的控制平面地址和老系统不同。桌面端的 Connector 进程启动时会连接到指定的控制平面,握手成功后建立长连接。
如果 Connector 进程还连着老系统,即使用户账号已经迁到新系统,Connector 也无法获取到新系统的指令。反过来说,如果 Connector 没有重启,它就不知道用户迁到了新系统。
这就是为什么迁移完成后必须通知用户重启桌面端 Connector。重启时 Connector 会重新寻址、重新握手、连接到新的控制平面。之后一切恢复正常。
如果用户没有重启,会卡在"正在尝试恢复连接"的状态。新的工具指令下不来,老的控制平面也有超时断开了,这个过程会很难受。
从两个用户到 574 个用户
两个测试用户的迁移用了半天。第一个用户(volume 型,约 329MB,7728 个文件):导出、上传、下载、解包、导入、验证,全程顺利。中间没有意外,密钥转换、设备表、clusterId 都对上了。
第二个用户(bind 型,约 75MB,4778 个文件):打包时踩了那个属主的坑——sudo 加上以后就通了。从这里开始意识到,bind 和 volume 的处理逻辑必须分开。
两个用户都通过以后,我把脚本参数化,改成了单用户批处理的形式。老系统的导出脚本接收 userId,自动判断挂载类型、选择打包方式、执行密钥转换。新系统的导入脚本接收 userId,自动从私有桶拉数据、解包、导入数据库。
全量执行时用批量循环调用这两个脚本。老系统可以较高并发上传(打包是 CPU 密集但量小),新系统的 build 机 CPU 是瓶颈,所以每批控制在 5-10 个用户。失败的用户记录下来,单独重试。脚本设计成幂等的,重跑同一个用户不会产生重复导入或覆盖。
整个过程没有"先完整设计再验证"的阶段。而是在两个真实用户身上把七个关键点全部踩了一遍,每个坑的解法都通过验证,然后把脚本一般化。这样到全量执行时,风险已经可控。
574 个用户的迁移在一个停机窗口内完成。中间有两个用户因为网络超时失败,重跑以后成功。最后逐个抽样验证,账号能登、数据完整、设备表存在、sub2api 调用成功。
密钥转换的幂等性证明了它的必要性:如果中间有用户的导入被中断,下一次重跑时新的加密过程会用最新的密钥重算,结果还是对的。这避免了"某个用户的密文版本是混杂的"这种中间态。
迁移执行中的可见性与回滚设计
全量迁移前,我准备了详细的进度跟踪和失败处理机制。每个用户的迁移过程记录到日志里:已导出、已上传、已下载、已解包、已导入。如果某个环节失败,日志会精确指出是哪一步、为什么失败。
失败不等于灾难,因为整个过程设计得是幂等的。重跑同一个用户的导出和导入脚本不会产生重复的数据库行。如果导入中途被打断,下一次导入会看到已有的用户记录,跳过创建步骤,只更新增量数据(比如新一轮的密钥转换)。
老系统的数据在整个迁移期间处于只读状态。没有删除任何源数据,只是读取、转换、上传。一旦新系统稳定运行,老系统作为完整的回滚备份而继续保活。这是保险的做法——如果新系统在某个时刻崩溃或数据破损,我可以立刻切回老系统,损失只是中间几个小时的新增数据。
两个测试用户通过后,迁移在一个停机窗口内展开。574 个用户分批处理,老系统的上传和新系统的导入并行进行。中间有两个用户因为网络超时导致下载失败,重新跑了一遍就成功了。这证实了脚本的幂等性和容错能力。
迁移完成后,我逐个抽样验证。登录账号,检查数据目录是否完整,跑 md5 校验和(与打包时的 hash 对比),用新系统的接口调用 sub2api 看密钥是否正确转换。设备表的记录数和新系统现存的设备数应该吻合。clusterId 在所有用户上是一致的 cce-1。
这些检查都通过以后,才通知用户切换到新系统,并提醒重启桌面端 Connector。
为什么数据迁移总是出其不意
事后看,七个关键点里有五个是在测试阶段才浮出来的。最初的设计文档对其中三个(bind 属主问题、设备表漏掉、SFS 挂载掉线)完全没有预见。
这不是设计不够仔细,而是这类问题的特性:它们涉及多个系统的交界面。bind 挂载的属主问题不会出现在任何单一组件的文档里,而是 Linux 文件系统权限、Docker 挂载、tar 命令的组合特性。设备表漏掉是因为新系统的功能链路和老系统不同,在纸面上看不出来。SFS 的挂载掉线需要真实的硬件重启来复现。
如果先花两周完整设计,再花两周编码实现,再花一周全量执行,那么遇到这些问题时已经是 go-live 前几个小时,后果会很严重。
改成"两个用户验证 → 踩坑固化 → 全量执行"的流程,则是在可控的范围内把风险提前释放。两个用户的数据量不大(总共不到 500MB),失败了重来也快。从踩坑到修复脚本,整个周期不超过一天。新系统的导入逻辑也是在这个过程中逐步完善的——先是最基础的"把数据解开、写进数据库",然后加上"密钥转换",再加上"设备表导入",最后加上"幂等检查和错误恢复"。
每一层都是在真实数据验证下加上去的,不是基于假设。
新系统稳定运行两周以后,我才把老 Swarm 系统下线。期间没有发现任何数据不一致或功能失效。574 个用户和它们的 124G 数据完整迁移到了新平台。
■