2022年双十一,我闲鱼搞了一个Radxa Rock 5B,还很有仪式感地写了篇使用体验。后来它从玩具一路混成了家里的低功耗服务器:接三块机械硬盘,跑下载、爬虫、网盘、文件管理、命令行历史、Tailscale网关,还有一个我自己用的Ask Codex。
三年多以后,我又闲鱼搞了一个Radxa Dragon Q8B。高通 SC8280XP 平台,Ubuntu 26.04,板子新,系统也新,看着很适合把老Rock 5B换下来。
问题是,我已经记不清Rock 5B上到底跑了些什么。
于是这次搬家基本交给了sol-5.6。
先说清楚,不是我让AI写一篇迁移教程,然后自己照着做。Codex就运行在Q8B上,可以SSH进Rock 5B,实际盘点两台机器、复制文件、导出镜像、恢复数据库、写systemd服务和检查结果。需要sudo密码、Cloudflare网页操作、物理关机拔硬盘的地方由我来。大部分时候我做的事情,就是看它给出的结论,然后回复“继续”。
当然,AI不但真的搬了,它也真的闯祸了。这个后面再说。
0x00 第一步不是复制,是查家底
我能想起来的东西不少:Tailscale subnet router、Cloudflare Tunnel、Ask Codex、hd-idle、Samba、五个Docker容器、几个代码目录、两个爬虫,还有Codex自己的历史、memory和skills。
我给AI定了两条规矩:
- 先只读检查,不许修改旧机;
- 没必要不要碰
/mnt,三块大硬盘睡着就让它们继续睡。
这个限制非常有用。AI没有上来就rsync -a /,而是先看systemd、Docker inspect、Compose、端口、Git状态、挂载元数据和进程。查完以后发现,我果然漏记了东西。
~/quant/weconn不只是一个“好像部署在远端的微信项目”,Rock 5B本机还有一个开机自启、已经连续跑了二十多天的watcher.service,它有自己的watcher.db和环境变量。amplist则一直躲在tmux里手动运行,也有一份正在写的SQLite。File Browser除了三块硬盘的bind mount,还有几个Docker volume;如果只记得容器启动参数,用户和配置就没了。
反过来,也排除了一些看着像服务、其实没有业务的东西:rpcbind没有NFS exports,OpenVPN没有配置,CUPS没有打印队列,vnstat只有旧网卡的流量历史。Douyin_TikTok_Download_API也没有被dkx-dl引用,我决定不迁。
两边都是aarch64,容器和源码总体没什么架构鸿沟。真正麻烦的是“活的东西”:正在写的SQLite、PostgreSQL、Docker卷、登录凭据和本机状态。还有旧机是Python 3.12,新机系统Python已经3.14,直接把.venv复制过去属于给未来的自己埋雷。
0x01 先把代码变成可以搬的样子
AI把旧机几个目录下的Git仓库都扫了一遍,列出未提交、未跟踪以及没有push的内容。我先自己把能提交的提交掉,watcher.db这种运行数据库放进.gitignore,然后才开始第一轮预复制。
目录保持原样:
~/jsws/ask-codex~/devof/amplist~/devof/dkx-dl~/devof/XHS-Downloader~/quant/weconn~/quant/trading-codex~/agentws第一轮只搬代码和普通文件,明确排除了.venv、node_modules、缓存、两份活SQLite和/mnt。大约69MB,旧机没停任何服务,新机原有的migration目录也没有被--delete顺手扬掉。
然后在Q8B上重建环境。Ask Codex的typecheck、lint、709项测试和ARM64构建都过了;weconn的74项测试也过了。dkx-dl有一项失败,最后查出来不是Q8B问题,而是代码已经把小红书恢复等待改成48小时,测试还坚持24小时。迁移顺便给原项目做了次体检。
Python统一用uv管理的3.12,不去折腾Ubuntu 26.04的系统Python。yt-dlp和gallery-dl一开始AI按旧机看到的版本装了,我想起来自己以前不是这么装的,于是让它重来:主程序用最新版,两个tool环境都带上curl_cffi==0.14和pycryptodomex,gallery-dl里再注入yt-dlp。ARM64上的curl_cffi有点挑版本,这种自己踩过的坑,还是人类的记性暂时赢了一回。
Playwright的ARM64 Chromium也重新下载,只开空白页做smoke test,没有把旧浏览器profile和登录态一锅抄走。
0x02 Docker镜像不能只看tag
旧机上有五个容器:Atuin、PostgreSQL、OpenList、Open WebUI和File Browser。
其中几个镜像只有本地SHA,tag要么没了,要么是latest/main。如果在Q8B上重新pull,很可能得到“名字相同、内容已经不是那一版”的新镜像。AI最后按正在运行容器的精确SHA,通过局域网直接做了docker save | docker load,大约1.88GB,不在旧机落中间tar,也不用停服务。
数据则分成两次:先在线预复制静态目录,并给Atuin做一份PostgreSQL逻辑备份;正式切换时再停写、重新dump和最后一次rsync。
做到这里,我让Codex把剩余步骤写进~/agentws/migration。还专门装了我自己的project-continuity skill,把“最初盘点”“当前进度”“Docker停机清单”“网络切换”“Samba切换”拆成几份文档。现在回头看,这一步救过场——后面真的有一小段聊天记录没了,执行到哪主要靠这些落盘文档找回来。
0x03 先把路接过去
Q8B和Rock 5B先同时广播192.168.1.0/24,做Tailscale subnet router的HA。这里特意没有加--accept-routes,不然standby可能把本来直连的家庭LAN流量又绕给primary。
测试方法很土但是有效:手机关Wi-Fi,开蜂窝网络和Tailscale,访问旧机上的File Browser。先确认Q8B转发正常,再给Q8B安排一个120秒后自动恢复路由的临时systemd任务,然后清空它的advertise route。手机还访问得了旧机,说明Rock 5B确实接管了。恢复Q8B以后也正常,于是新机primary、旧机standby先这么留着。
Ask Codex不适合两边同时跑。它看到的是各自机器的工作目录和Codex状态,如果Cloudflare把请求随机分给两台,网页看着像一个服务,脑子其实是两个。所以我直接停掉旧机实例,把Q8B上的Ask Codex改成了ask-codex@radxa.service。
systemd不会加载我的.zshrc,Codex又得找得到cargo、npm、pnpm、uv之类的工具。最后没有在启动脚本里activate conda,也没有每次跑一遍nvm use,而是在服务环境里明确设置HOME和完整的工具PATH,Node则直接指向当前版本。服务起来以后,下面还跟着真正的codex app-server进程,不是只有网页空壳:
node dist-server/index.js└─ codex app-server --listen stdio://Cloudflare Tunnel继续用原来的远程配置。token没有从聊天或命令行参数里路过,而是放在root-only、0600的/etc/cloudflared/token。顺序是先停旧connector,再启Q8B connector;metrics显示四条边缘连接都起来以后,我从浏览器重新过Cloudflare Access和新的Ask Codex应用token。公网入口没变,后面的机器换了。
0x04 AI迁自己的脑子,然后把自己绊了一跤
旧机有52个Codex session,新机也已经有自己的会话。整个~/.codex覆盖肯定不行,登录状态、配置、memory、skills和新版SQLite索引都会互相踩。
AI先在/tmp里做了一个隔离的CODEX_HOME,把两边rollout JSONL放进去,让Q8B上的新版Codex自己重建索引。确认旧会话能列出、能恢复以后,才做正式切换。第一次切完又发现索引里的rollout_path还指向staging目录,于是备份数据库,用一个事务改回正式sessions路径。
紧接着又出现一个很有Codex特色的问题:旧会话在各自原cwd下还是看不到。查了半天,原来旧会话写的是custom provider,新机默认叫openai。补上custom以后,旧会话出现了,新机的migration会话又消失了——列表还会按provider过滤。
我让它干脆把旧会话里的custom统一改成openai,然后恢复默认配置。它在副本里演练成功,写了一个带备份和回滚的脚本,改了数据库里52条记录和JSONL里的82处元数据。刷新网页以后,新旧会话终于都在。
但我发现,刚才讨论停服务、关机和搬硬盘的几轮对话不见了。
数据库quick_check是ok,不是SQLite坏了。日志也能证明那几轮确实发生过,migration文档的修改时间和内容都对。最后查明,AI写的转换脚本只停了systemd里的Ask Codex,不知道我另一台电脑的VS Code通过Remote SSH还在Q8B上跑着另一个codex app-server。脚本用perl -pi替换JSONL,相当于创建新文件再换掉旧文件;VS Code的旧进程还握着已经被取消目录链接的inode,继续往里面写。
然后我直接poweroff了Q8B。
文件句柄消失,那几轮也彻底没了。
所以,这次迁移最严重的事故,是AI迁移自己的聊天记录时,没数清到底有几个自己正在写文件。硬盘没坏,数据库没坏,业务也没丢,偏偏迁移过程本身少了几句话。幸好它此前坚持把每一步更新到runbook里,才能从文档和日志还原到“旧机服务已经停、两台机器都关机、三块盘已经换过去”。
后来脚本规则也补上了:以后动Codex session文件以前,不仅要停Ask Codex服务,还要关掉所有Remote SSH里的Codex插件,并确认没有任何codex app-server进程。
AI确实很会做备份,但它也需要有人提醒:你到底有几个AI。
0x05 三块硬盘,真的用手搬
AI能SSH,不能拔SATA线。停掉旧机的OpenList、File Browser、Samba和hd-idle,确认没有占用以后,我把两台板子都关了,把16T、8T、1T三块NTFS硬盘从Rock 5B的ASM1166卡上按原位置挪到Q8B。
开机后的第一件事不是挂载,而是对序列号、WWN、UUID和ASM1166端口。三块盘仍然对应ATA 1/4/6,旧机也确认没有误挂载。然后才跑ntfsfix --no-action,全部通过以后以只读方式挂到原路径:
/mnt/exos_16t/mnt/exos_8t/mnt/exos_1t这期间没有遍历盘内目录。确认文件系统和身份都没问题后,才写入fstab、改成读写挂载并做最小读写测试。
硬盘休眠继续保持300秒,不过不再依赖sda/sdb/sdc这种可能换号的名字,而是每块盘各自的/dev/disk/by-id/。hd-idle做成systemd服务,日志只进系统盘上的journald,不给它配写在机械盘上的日志文件。五分钟以后,日志里三块盘依次spindown,hdparm -C也都返回standby。
这时候三块盘才算真正搬完,不是mount成功就算完。
0x06 两个小数据库,反而要最后搬
代码第一轮复制时故意留下了watcher.db和amplist.db。现在硬盘和服务都停稳,才轮到它们。
Rock 5B重启后我没有重新运行amplist,所以先复制它的数据库;watcher.service还活着,我手工disable --now以后再搬。两份库都检查了WAL/SHM、跑quick_check,复制前后对SHA256,新机再跑integrity_check。
我还提醒AI watcher“好像有腾讯申请的key”。它没有把.env打印出来,而是只核对路径、权限和哈希。最后发现真正需要保留的是推送用的bearer token,腾讯行情本身走公开接口;环境文件已经原样迁到Q8B,权限顺手从0664收紧成0600。
两个原本一个靠旧systemd、一个靠tmux手工吊着的程序,这次都正式做成了服务。watcher启动后在非交易时段正常跳过监控项;amplist第一次循环抓到25条、没有新任务,然后睡600秒。
它启动时正常唤醒了1T盘。又等300秒,hd-idle把盘重新睡下去。爬虫和省电策略算是和平共处了。
0x07 容器恢复,比想象中碎
Atuin最先做正式停机切换。先停应用,让PostgreSQL继续运行并导出最终dump;在Q8B用同版PostgreSQL验证备份清单以后,才停旧数据库。新库从空目录恢复,用户、session和store记录数逐项对上,再启动Atuin。
旧机的两个Atuin容器在停止数据库的同一秒被Docker daemon销毁了,尽管inspect里AutoRemove=false,执行的也只有docker stop。原因没彻底查清,好在Compose、旧数据库目录和最终dump都在,回滚只是从“docker start旧容器”变成“用旧Compose重建”。这是AI很擅长的一件事:出怪事的时候不先宣布成功,先把还能回滚的东西数一遍。
Atuin客户端只需要把sync_address从旧IP换成新IP。数据库里的session一起迁了,不需要重新login,加密key也不用换。后来Q8B自己作为一个已登录客户端跑atuin sync成功,旧历史还在。
OpenList和Open WebUI停机后做最后一次rsync,第二次dry-run都是0漂移。File Browser那边,AI看到文件名叫filebrowser.db,一开始拿SQLite去验,当然失败;再看文件头才发现它是BoltDB,最后用同一版File Browser二进制执行config cat和users ls才确认能读。文件名有时候比AI还会骗人。
Open WebUI也藏了一个坑:WEBUI_SECRET_KEY在旧容器可写层里,不在挂载的数据目录。直接重建会生成新key,旧网页登录session就失效了。AI发现新旧key哈希不同以后,把旧key单独保存成宿主机0600文件,再只读挂进新容器。镜像自带的healthcheck还因为一条有问题的jq ... break把正常HTTP 200报成unhealthy,最后也换成直接检查/health。
期间AI还连续踩了两次zsh:先把变量叫path,把自己的PATH弄没了,提示找不到tar和wc;后来又用了zsh的特殊变量options。两次都没有伤到源数据,只是很像一个熟练程序员在半夜写迁移脚本时会干出来的事。
0x08 Docker管容器,systemd管硬盘有没有来
最初AI给Open WebUI、OpenList和File Browser都套了systemd。我问它:Docker自己不是会重启吗?
答案是,systemd不是为了比Docker更会养容器,而是为了挡住一个很阴的情况:机械盘启动时没挂上,/mnt/exos_1t这个目录依然存在,只不过它其实落在系统NVMe上。Docker照样会把这个空目录bind mount进去,OpenList也照样会开开心心往里面写。等真正的硬盘挂上来,那些新数据反而被盖住了。
所以OpenList和File Browser保留systemd管理:启动前检查所需路径必须是真实、可写的mount point,容器自己的restart policy设为no,避免Docker绕过门禁。Open WebUI不依赖HDD,没有这个问题,后来又拆掉多余的unit,交回Docker的unless-stopped。Atuin继续由Compose管理。
不是“统一由谁管理”最好看,而是谁需要知道硬盘到底在不在。
0x09 最后把Samba接回来
旧机有效的Samba业务就三个共享:EXOS_16T、EXOS_8T、EXOS_1T,都允许radxa读写。Q8B照原名字和原路径恢复,这样家里其他设备只需要换服务器IP。
没有跨Samba版本硬抄passdb.tdb,而是在新机重新建立Samba密码。密码也不在smb.conf里,以后要改就是:
sudo smbpasswd radxasmbd同样加了三盘真实读写挂载门禁;老式NetBIOS发现我用不上,nmbd继续关着。安装和检查配置的过程只读挂载元数据,不列硬盘目录。真正从客户端打开共享、创建和删除测试文件,留给我自己验收——这个动作本来就一定会唤醒硬盘。
0x10 搬完了吗
到这里,Q8B已经接管了:
- Tailscale家庭网段路由;
- Ask Codex和Cloudflare Tunnel;
- Atuin/PostgreSQL、OpenList、Open WebUI、File Browser;
- watcher和amplist;
- 三块NTFS硬盘、300秒休眠和三个Samba共享。
旧Rock 5B还留着,文件和配置也都没有删。Tailscale暂时继续当standby,等新机多跑几天再退休。旧机还有一个inactive但enabled的atuin.service要禁用,免得哪天开机又把旧服务拉起来。OpenList、File Browser和Samba也还需要我从日常客户端做最后的登录、网盘和读写验收。
这篇不是一份可以照抄的迁移教程。里面不少脚本绑定了我自己的目录、磁盘身份和服务名,换台机器直接跑,大概会得到一场很有教育意义的事故。
但这次确实让我第一次把“AI操作真实服务器”用到了比较完整的程度。它不只是给命令,而是先查旧机到底有什么,区分静态文件和活数据,做预复制、停写、校验、回滚,再把临时决定写进文档。很多我不会主动想到的边角——File Browser卷、Open WebUI的secret key、HDD没挂载时写进系统盘——都是它翻出来的。
同时,它也用丢掉自己几轮对话证明了一件事:AI写的安全脚本,仍然可能漏掉它不知道的进程;“有备份”也不代表已经数清所有写入者。人类最后还是要看输出、做决定、拔硬盘,以及在AI说“完成”以后多问一句:
“真的都停了吗?”