不需要额外磁盘空间的服务器迁移:用 FastFileLink 串流 Borg 备份

在 Fri 24 April 2026 发布于 Blog 分类

搬一个 production app,表面上像是把资料复制到新机,实际上往往是在跟时间、空间和环境条件搏斗。

最常见的情况是这样:旧机快满了,大家想尽快搬走;新机刚准备好,还没正式接手流量;DNS 不能立刻切;资料量又大。这时如果 migration 流程还要求旧机先多生出一份大型 tarball,通常只会让事情更难推进。

这篇文章整理的是我们最近改造 migration 流程的实战经验。我们本来用的是很典型的做法:先用 Borg 备份,再把备份汇出成 tar 或 tar.gz,接着透过 scp 传到目标机器,最后在新机还原。

那套方式在磁盘空间充裕时很好用,也很好调试。问题出在它对来源机器的空间要求太高。偏偏真正急着搬的主机,通常就是空间最吃紧的那一台。

所以我们把传输层改成以 FastFileLink CLI 为核心,让资料从 Borg 直接串流到目标端。在这条流程里,来源端不会先落一份 tarball,目标端也不会先下载一份 tarball 再解压。资料会一路从旧机流到新机,直接还原到定位。

这个做法不只适合我们自己的部署系统。只要你有大量备份要搬、要把 production 资料还原到 development VM,或想在没有事先配置 SSH trust 的情况下做一次性大档传输,这个模式都很值得采用。

? FastFileLink CLI ?? Borg ????????

我们的部署模型

我们内部有一套轻量的部署系统,概念上接近小型 PaaS,主要入口是 Deploy.py

它会处理 install、backup、restore、migrate、update、health check 和环境准备等维运工作。共通流程集中在部署层,app 特有的细节则交给各 app 自己的维运脚本与设定。

我们在这个场景没有选 Kubernetes,理由很单纯:对目前这类工作负载来说,它的整体复杂度偏高。很多服务是小到中型的 production app,我们更在意的是单机维运流程清楚、脚本好读、复原步骤可预期,而且能从 app 目录一路追到整个生命周期。

在这种前提下,保留一套结构化、但不过度庞杂的部署界面,比导入完整丛集编排更合适。

每个 App 自带 Working Folder 的好处

这套设计里,一个很关键的选择是:每个 app 都有自己的 working folder。

那个目录里会放 app 的执行配置、container 定义、service scripts、app 自己的维运工具,以及 persistent volumes 的还原方式。数据库则透过 dump、backup hook 或相关工具纳入同一套维运契约。

换句话说,如果能在另一台机器重现以下几个部分,app 基本上就能先在新机跑起来:

部分 作用
Container 定义 重建 service process 与 runtime image
Volumes 保留上传档、使用者资料与需要持久化的状态
Database 保留结构化资料
Configuration 重现 domain、port、path、secrets 与环境变数
App 维运工具 让新机也能用同一套 install、backup、restore、migrate 界面

这也是为什么每个 app 都会有自己的 bin 工具,例如 backupinstallrestoremigrate 等。全域的部署系统会呼叫这些工具,但 app 内部的行为仍由 app 自己掌握。

这种设计刻意保持朴素。对 production 维运来说,朴素常常是优势。

旧版 Migration 流程长什么样子

原本的 migration 流程大致如下:

source server
  -> 执行 Borg backup
  -> 从 Borg 汇出 volumes
  -> 在磁盘上产生 tar 或 tar.gz
  -> 用 scp 传到 target server

target server
  -> 接收 archive
  -> 解压缩 archive
  -> 还原 database 与 config
  -> 启动 app
  -> 切流量前先验证

这是很容易理解的一条路。Borg 适合做备份,scp 几乎每个维运人员都熟,tarball 也很方便检查。

真正麻烦的是磁盘使用模式。

痛点就在来源机器快没空间

假设某个 app 的 volumes 有 80 GB。

旧流程很可能让来源机器同时承受这些空间需求:

既有 application data
+ Borg repository 或 backup cache
+ 汇出的 tar archive
+ 额外的压缩档
+ 传输中的 partial file
+ log 与暂存档

这种需求和 migration 的动机其实互相冲突。会急着搬机器,通常就是因为旧机太满、太旧、太难再撑下去。这时再要求它额外生出一份大型副本,等于把最重的负担放回来源机器身上。

更合理的做法,是让备份输出直接进入传输流程,不再经过来源端的落地档案。

rsync 能不能解这个问题

如果来源资料本来就是一棵可以直接同步的档案树,而且 source 与 target 之间的 SSH 通信也早就配置完成,那 rsync 依然很好用。

rsync -aHAX --numeric-ids --partial --partial-dir=.rsync-partial \
  /srv/apps/myapp/volumes/ user@target:/srv/apps/myapp/volumes/

它能续传、保留 metadata,也很适合同步现成目录。如果 migration 的意思只是「把 live volumes 复制到新机,然后把 app 起起来」,那 rsync 其实很合理,很多时候也可能比这类带 relay 能力的工具更快。

我们这次的情况不太一样。我们本来就会在 migrate 前先做一次备份。既然备份已经建立完成,用它当资料来源就很自然。新的 Borg archive 等于是一个干净、明确的时间点快照,而 borg export-tar 则刚好提供了现成的 stdout stream,可以直接接到传输流程里。

这里要特别说明一点:Borg 并不是 migration 存在的原因,它只是我们已经在使用、也已经信任的备份机制。做完备份之后,它顺手成了 migration 的资料来源。至于完整的备份历史记录,要不要一起带到新机,是另一个决策。在很多环境里,这其实不是必要条件,因为整台 server 本来就已经有另外的整机备份。

另一个因素是 target 的弹性。有些 target 是干净 VM、临时测试机,这时我们未必想先处理 SSH key、sshpass 或额外的 server-to-server 信任配置。

在这样的条件下,stream-oriented 的传输工具就会更顺手。

为什么 FastFileLink CLI 很适合

FastFileLink CLI 可以传档案、资料夹,也可以传 stdin。这正是整个设计改变的起点。

比起先汇出 tarball,再把 tarball 复制到目标机器,我们可以让资料一路保持在流动状态:

borg export-tar -> stdout -> FastFileLink CLI -> stdout on target -> tar extract

在这条流程里,来源机器不需要先落一份 tarball,目标机器也不需要先收一份 tarball。

来源端的指令形状大致如下:

borg export-tar "$BORG_REPO::$ARCHIVE" - \
  | "$FFL" - \
      --name "$APP_NAME-volumes.tar" \
      --e2ee \
      --stdin-cache off \
      --max-downloads 1 \
      --pickup-code "$PICKUP_CODE"

目标端则直接把收到的资料送进 tar

"$FFL" download "$LINK" \
  --pickup-code "$PICKUP_CODE" \
  --e2ee \
  --stdout \
  | tar xvf - -C "$RESTORE_ROOT"

这代表:

来源机器:不落地汇出 archive
目标机器:不落地下载 archive
传输过程:资料一路串流到目标目录

这次主示例不把 gzip 放进来,也是刻意的取舍。因为这次 migration 的主要痛点是额外磁盘空间,不是压缩率,而 plain tar stream 可以让 source 和 target 两端少一层 CPU 负担,也更容易调试。

对低空间 migration 来说,这正是最想看到的形状。

--stdin-cache--stdout--pickup-code 在做什么

如果只是把上面的指令贴出来,不多解释,读者其实很难立刻理解这几个选项的意义。

先说 --pickup-code。它是这条配对流程的一部分。sender 会产生一个 link,但 receiver 除了 link 之外,还要知道 pickup code 才能真正取走资料。如果你用过 croc,应该会对这种短码信任模型很熟悉:一组短码会成为取件流程的一部分。放在 migration 这里,它的好处很直接,因为我们可以把 link 和 code 一起交给 target process,不必把下载入口完全裸露出去。有了 --pickup-code 之后,FastFileLink CLI 其实就已经知道这次用的是 pickup 模式,所以不必再另外把 --recipient-auth pickup 写出来。

FastFileLink CLI 也不只有这一种收端验证方式。除了 pickup 之外,CLI 还支援 pubkeypubkey+pickupemail 等模式,也支援用 --auth-user--auth-password 走传统的 HTTP Basic Authentication。这次我们选 pickup,是因为它在脚本化流程里很方便,对一次性的 migration 也很顺手。

再来是 --stdin-cache off。FastFileLink CLI 默认的心智模型,偏向一般档案分享:档案可能会被重复下载、也可能会有多个 receiver。因此它会考虑 cache,这样才能支援多次取用。但 migration stream 的情境刚好相反:我们知道这份资料只会被一个 receiver 消费一次。如果还保留 stdin cache,只是在来源端额外花磁盘空间。把 cache 关掉之后,来源端就更接近真正的单次串流 producer。

--e2ee 在这里也很值得开启。WebRTC 本身已经走安全传输,但 migration 过程有时会 fallback 到 relay 或 tunnel。把 end-to-end encryption 打开之后,即使资料走到中继路径,中间节点看到的也只是加密后的内容。对备份资料和 production data 来说,这层保护很有价值。

download --stdout 则是 target 端的关键。接收端拿到位元流之后,可以直接往 stdout 写,后面接 tar 就能原地解开,不需要先下载到一个 tarball,再做第二步解压。

合在一起之后,整个资料路径会变得很清楚:

source app backup snapshot
  -> Borg export stream
  -> FastFileLink CLI sender
  -> FastFileLink CLI receiver
  -> tar 直接解到 target app folder
  -> volumes 就定位

怎么整合回既有的 Deploy.py

我们没有重做一支全新的 migration 工具。

原本的 Deploy.py migrate 已经很清楚自己要做什么:

  • 准备 source / target app layout
  • 呼叫 app 自己的 migrate / backup 脚本
  • 汇出 volumes
  • dump 与 restore database
  • 还原 config
  • 启动 target app 并验证

因此改造的重点在于把传输层换掉,让其他既有能力继续工作。

整体流程现在可以理解成:

Deploy.py migrate
  -> app bin/migrate
  -> ExportVolumes from Borg
  -> source 端串流到 FastFileLink CLI
  -> target 端从 FastFileLink CLI 接出来
  -> 直接解到 target app folder
  -> restore database 与 privilege
  -> 视需要传送 backup history
  -> install / start target app
  -> cutover 前验证

这样做还有一个好处:原本的 file-based 路径依然留着。当你需要保留 artifact、想把 restore 问题切开来 debug,或在空间充足的情况下想保留旧流程时,仍然有选择。Streaming mode 则负责解决低空间 migration 的核心问题。

FastFileLink CLI 在这个案例中的几个优势

FastFileLink CLI 跟这个场景贴得很近,主要是因为它同时满足了几个需求:

能力 对 migration 的帮助
单档 APE binary 需要时下载,用完删掉,不必预先安装
WebRTC direct transfer 网络允许时尽量点对点传输
Relay fallback 打不通直连时仍能透过 relay 完成
End-to-end encryption relay 看不到明文内容
Pickup code 验证 脚本可以安全配对 sender 与 receiver
--e2ee 即使走 relay 或 tunnel,中间节点也看不到资料内容
stdin / stdout 支援 真的能做到两端都不落地的串流搬迁
--hook 事件输出 很适合整合到既有 automation 里

对老旧 production 主机来说,单档 binary 的意义很实际。我们不需要为了搬走它,先在上面长期安装一个新服务。抓下 ffl.com,跑完流程,清掉档案即可。

--hook 这点也很值得单独讲。FastFileLink CLI 有它自己独特的 embedded mode,可以输出结构化事件拿来做整合。这帮我们省掉很多胶水程序,因为不必再去 parse 人类看的 console output 才知道传输目前发生了什么。这种整合面在同类型工具里并不常见。

和 scp、rsync、croc、Magic Wormhole 的比较

挑工具时,真正要看的还是它的假设是否符合现场条件。

工具 适合情境 这个案例里的限制
scp 有现成档案要透过 SSH 复制 需要先产生 artifact,也要处理 SSH 认证
rsync 同步 live directory tree 不擅长直接搬 Borg stdout stream
croc 安全的 CLI 传输,也支援 pipe 是很好的替代方案,但大量资料时常会走 relay,对多百 MB 或多 GB 的 migration 不是特别理想
Magic Wormhole 人工一次性安全传档 比较偏 ad-hoc 传送,不是 unattended migration 的最佳主线
FastFileLink CLI 档案、资料夹与 stdin/stdout 串流 需要把 timeout、cleanup、log 观测一起设计好

如果资料已经是可直接同步的目录,而且 SSH trust 很稳定,rsync 仍然会是很强的选项。

但在「资料本来就是备份 stream」加上「目标端可能是新 VM」这种情况下,FastFileLink CLI 的整体手感明显更好,因为它对环境的前置要求更少,同时保留加密、验证、直连与 relay fallback。

实作过程里,真正踩到哪些坑

真正有价值的教训,并不是某条路径 quote 错了,或哪个 cleanup 顺序放反了。那些问题确实存在,也确实修掉了,但大量资料传输真正难的地方,是状态观测。

我们最早的版本走得很直觉:启动 FastFileLink CLI,parse 一般输出,把 link 抓出来,剩下的流程再用 shell wrapper 补。当 proof of concept 没问题,但当它变成真正的 migration command,就会开始暴露观测上的不足。

大档传输把这些问题放得很大:

  • sender 到底有没有开始读 stdin?
  • receiver 真的接上了吗?
  • 目前走的是直连,还是 fallback 路径?
  • process 还活着,代表真的在前进,还是其实只是卡住?
  • producer 和 transfer tool,究竟是哪一端先失败?

Orchestration 比 pipe 更重要

Streaming migration 绝对不是「把 producer 接到 consumer,中间多一条 pipe」就结束。

file-based transfer 失败时,通常还会留下 partial file 可以看。stream 一旦卡住,系统自己就得知道到底卡在哪里。真正能不能拿来做 production migration,差别常常不在传输工具支不支援 stdin,而在整个 orchestration 有没有把状态观测、清理与重试边界一起设计好。

因此实作上至少要补几件事:

关注点 做法
temporary binary ffl.com 下载到 temp directory,用完就删掉
logging 卡住时能切到 DEBUG,或指定 logging config 帮助排查
receiver readiness 先确认 target receiver 已启动,再让整体流程往下走
progress 观察 bytes transferred 或 extracted,不只看 process 还在不在
cleanup 清掉 orphan sender / receiver process 与临时档
retry 在 transfer boundary 重跑,不从半个 tar extraction 中间硬接
secrets auth password、pickup code 这类资讯不应出现在一般 log

我们实测时也很明显感受到这件事:如果 target receiver 根本没跑起来,source 端表面上可能还在等、还像是在做事,但整条 migration 其实早就停住了。

所以一个够稳的 migration command,至少要能确认:

source process is alive
target process is alive
target output is growing
logs show an accepted transfer path

长时间传输里,「process 还活着」从来不等于有进度。进度一定要能观测。

等我们改成用 FastFileLink CLI 的 --hook 之后,事情顺利很多。share link、progress、receiver 状态、完成与失败事件,都可以透过结构化事件来追。这时候才真正摆脱「把人类看的输出当 API」这种脆弱作法。

这件事的价值非常高。migration automation 需要的不只是资料有没有在传,而是流程状态能不能被可靠地理解。

在这个基础上,我们当然还是修到一些一般 deploy bug,例如 target 路径 quoting、旧 volumes 的清理顺序等。但那已经是能清楚定位、也能直接修正的问题了。

修完之后,整条 migration 已经能顺利完成这些步骤:

  • 从 Borg 串流 volumes 到 FastFileLink CLI
  • 在 target 还原 database dump
  • 还原 database privilege
  • 视需要把 Borg backup repository 一起带过去
  • pull app image
  • 启动 target container

我们最后遇到的剩余问题,已经不在 streaming layer,而是 target 上 Nginx / SSL 收尾的部署细节。这反而是个好讯号,表示最困难的资料搬运路径已经打通,接下来处理的是一般部署尾声该处理的事情。

不切 DNS 也能先测试 migration 结果

好的 migration 流程,应该允许我们在真正切换前,先验证新机是否已经能接手。

这一点和我们的 app folder 设计很契合。因为 app 的 container、volumes、database、config 与维运工具本来就被整理在同一套 working folder 契约里,所以可以先把整个 app 还原到新机,用测试 port 或 temporary hostname 启动,确认结果没问题,再安排 DNS 或 load balancer 的 cutover。

实际上很容易整理成这样一份 checklist:

1. 在 target 还原 app files、volumes、database、config。
2. 启动 target 上的 containers 或 services。
3. 执行 health checks。
4. 验证 static files 与 uploaded files。
5. 验证 database-backed pages 和 login flow。
6. 检查 logs 是否有 path、permission、environment variable 问题。
7. 确认后才安排 DNS 或 load balancer 切换。

这也是 migration 没那么可怕的关键。旧 production server 可以继续服务,新机则先在旁边证明自己真的能接手。

这个模式不只适用于单一 App

虽然这次是从一个具体 migration 案子长出来的,整个模式其实很通用。

只要资料来源能输出到 stdout,就可以沿用同样的传输思路:

borg export-tar repo::archive -
pg_dump mydb
mysqldump mydb
tar -cf - /large/folder
zfs send pool/dataset@snapshot

接着把 stream 接到 FastFileLink CLI:

producer | ffl - --stdin-cache off --max-downloads 1

目标端再接到真正的 consumer:

ffl download "$LINK" --stdout | consumer

这很适合用在:

  • 老旧或磁盘快满的 server 搬迁
  • 把 production backup 还原到 development VM
  • disaster recovery 传输
  • 大量使用者上传资料搬移
  • 不想在来源机留下 dump file 的 database 传输
  • 事前不想配置 SSH trust 的临时环境

结语

这次 migration 改造最重要的收获,在于我们把整个搬迁契约重新整理过。

旧流程要求来源机器先准备出一份大型副本,搬迁才有办法开始。当旧机已经快满时,这个前提很容易成为第一个阻碍。

整理之后,流程会变得很直接:

读出 backup stream
立刻传走
在 target 直接还原

--stdin-cache offdownload --stdout 很适合这条路径。对正在处理 server migration、备份传输、低磁盘空间主机,或临时还原环境的团队来说,这是一种很务实、很好自动化,也很容易说清楚的解法。

当旧机空间告急、搬迁又势在必行时,这样的行为模型会让整个流程轻松很多。