从一次凌晨的发布事故说起,我决定不再用rsync硬扛镜像同步了。当时我们有两个内网镜像仓库,主节点在上海,灾备节点在贵州,每次大版本发布前都要把几百GB的容器镜像和静态资源包推到备节点。最初用rsync加crontab看似没毛病,但真正跑起来之后才发现噩梦才刚刚开始:同步到一半网络抖动全部重来、源目录删除文件后备份节点被连带删除、几十个小文件并发时rsync的session开销大得离谱。连续两次凌晨被on-call电话叫醒之后,我把“搞一个真正适合镜像仓库场景的同步工具”写进了迭代计划,这就是MiroFish的由来。
MiroFish定位很明确:一个面向容器镜像仓库和静态资源目录的增量同步CLI工具,核心能力包括分块级校验、断点续传、并发传输、安全删除保护,以及一套简单但够用的任务编排机制。它不追求替代rsync的全部功能,而是把“镜像仓库同步”这一件事做透。项目从设计到上线大概用了三周,目前在我们内部承担着跨地域全量仓库同步和每日增量巡检的任务。这篇文章会把整个工具的选型逻辑、核心实现、部署实践和踩坑过程完整拆开来讲,适合正在被镜像同步问题折磨的运维和平台开发同学参考。
1. MiroFish到底解决了什么问题:从一次凌晨的发布事故说起
1.1 rsync同步镜像仓库的四个死穴
先说那次事故。我们有一个预发布流程,每周五晚上把构建产物和镜像同步到灾备节点。某个周五,网络恰好出现间歇性丢包,rsync跑到37%的时候连接断了。因为没有断点续传机制,第二天重新执行的时候,rsync会把已经传完的部分重新扫一遍、重新传一遍。更麻烦的是,镜像仓库里有很多几百MB到几个GB的大层文件,chunk一旦传了一半断掉,整个文件就要作废重来。那次从凌晨两点重试到早上六点,最后人盯在那儿手动调参数才赶在发布前勉强同步完。
这暴露了rsync在镜像同步场景里的四个死穴:
- 无分块级断点续传:rsync虽然能跳过已同步的块,但前提是文件完整地存在于目标端,而且它自己的临时文件机制在大文件场景下很不友好。
- 删除操作没有保护:
--delete一把梭,源端误删或者同步目录绑错,目标端直接跟着删,镜像仓库的数据安全完全没保障。 - 小文件并发能力弱:单线程rsync同步几十万个manifest文件时,速度可以慢到让你怀疑人生。
- 校验粒度过粗:默认只做文件大小和mtime校验,对于仓库里大量“内容变了但mtime被刻意保留”的文件,rsync会直接跳过。
那次事故之后我意识到,与其反复给rsync打补丁,不如写一个专门为镜像同步场景设计的工具。MiroFish就是在这个背景下立项的。
1.2 MiroFish的命名和核心设计目标
MiroFish这个名字有两层含义:Miro取自Mirror的前四个字母,代表镜像仓库;Fish则是取“像鱼群一样并发行动”的意思。整个工具的核心设计目标从一开始就很清楚:
- 分块级增量同步:把每个大文件切成固定大小的chunk,记录每个chunk的哈希,只传输目标端缺失或损坏的chunk。
- 断点续传落地:在目标端落地
.mf_partial文件,同步中断后恢复时,从记录文件里读取已完成chunk列表,而不是重新扫全量。 - 安全删除保护:引入“软删除”机制,目标端多出的文件不直接删,而是移入一个可配置的隔离目录,保留N天后才清理。
- 并发调度内部化:不再依赖外部
-P参数和手动调优,工具根据CPU核数、文件大小和网络带宽自动决定并发度。
后续所有代码实现和部署方案都是围绕这四个目标展开的。
2. 设计取舍与核心机制:为什么我不用rsync直接硬同步
2.1 全量扫描 + 增量传输的底层模型
MiroFish的同步模型可以拆成三个阶段:扫描(Scan)、计划(Plan)、传输(Transfer)。
扫描阶段做的事情是对源端和目标端分别做一次目录遍历,把每个文件的相对路径、大小、mtime和分块哈希列表记录下来。这里有一个关键取舍:MiroFish不采用“先扫源端再连目标端比对”的流式方案,而是把两端的清单都先落盘缓存,再进行离线比对。这么设计的原因很实际——镜像仓库的目录树动辄几十万条记录,网络往返一次获取单个文件状态的代价很高,离线比对一次生成全量差异列表,后续传输阶段只需按照差异列表执行,不再回源判断。
差异比对的核心逻辑伪代码如下:
def build_diff(source_manifest, target_manifest): diff = [] for rel_path, src_meta in source_manifest.items(): tgt_meta = target_manifest.get(rel_path) if tgt_meta is None: diff.append(("CREATE", rel_path, src_meta)) elif src_meta.size != tgt_meta.size: diff.append(("REPLACE", rel_path, src_meta)) else: src_chunks = src_meta.chunk_hashes tgt_chunks = tgt_meta.chunk_hashes missing_chunks = [ (i, h) for i, h in enumerate(src_chunks) if i >= len(tgt_chunks) or tgt_chunks[i] != h ] if missing_chunks: diff.append(("PATCH", rel_path, src_meta, missing_chunks)) return diffPATCH类型是MiroFish和普通同步工具拉开差距的地方。当文件大小一致但chunk哈希不一致时,不需要整文件重传,只需要传变化的chunk块。这在镜像仓库场景收益很大,因为很多情况下只是OCI manifest里的某个字段变了,或者配置文件被修改后又改回来,基础层文件完全没有变化。
2.2 分块大小和哈希算法怎么定
分块大小是MiroFish的第一个关键参数。我最终定的是4MB,这个值不是拍脑袋定的,而是基于两个约束:
- 镜像层文件普遍在几十MB到2GB之间,4MB的分块可以把大文件切成几百个块,粒度足够细,断点续传时损失可控;
- TCP窗口和磁盘IO在4MB块下能跑出比较理想的吞吐。我实测过1MB和8MB分块的传输效率:1MB块在网络良好时同步速度只有4MB方案的约七成,因为块头校验和状态记录的额外开销占比太高;8MB块虽然大文件传输效率略有提升,但遇到文件局部修改时多传的数据太多,PATCH收益急剧下降。
哈希算法选择了BLAKE3。和MD5、SHA256相比,BLAKE3在x86_64上的计算速度大约是SHA256的4到6倍,在多线程环境下还能并行处理多块数据。镜像仓库同步是典型的IO密集+CPU敏感任务,扫描阶段要计算几十万个文件的哈希,用BLAKE3能把扫描时间从“小时级”降到“分钟级”。虽然BLAKE3不是加密安全哈希的最保守选择,但镜像同步场景不涉及对抗性攻击,性能优先完全成立。
2.3 为什么并发模型从多进程改成了协程
第一版MiroFish用的是Python multiprocessing进程池,思路很简单:每个worker进程负责一个文件的全量传输。跑了一次全量同步之后,发现进程模型在断点续传场景下非常笨重。一个worker进程挂掉之后,它持有的继续传输状态就丢了,其他worker无法接管;进程间的内存共享又需要引入multiprocessing.Manager,复杂度上去了,性能反而下来了。
第二版改成asyncio协程模型后,几个长期困扰的问题迎刃而解:
- 状态集中管理:一个全局状态对象(resume_state)在协程间共享,每个chunk传完就更新,进程崩溃或任务取消后,新的协程能直接从状态对象里恢复进度;
- 连接复用:所有chunk传输复用同一个aiohttp TCP连接池,避免了进程模型下每个进程单独建立连接的开销;
- 动态调度:可以精确控制某个大文件的未完成chunk数,剩余chunk少的时候自动降低它的调度优先级,避免“一个大文件占满所有连接”的情况。
协程模型的关键实现是靠一个ChunkScheduler类完成的:
class ChunkScheduler: def __init__(self, concurrency=16): self.semaphore = asyncio.Semaphore(concurrency) self.pending = asyncio.PriorityQueue() async def schedule(self, file_queue): while not file_queue.empty(): file_task = await file_queue.get() for chunk_id, chunk_hash in file_task.missing_chunks: await self.pending.put((file_task.priority, chunk_id, chunk_hash)) while not self.pending.empty(): _, chunk_id, chunk_hash = await self.pending.get() async with self.semaphore: await self._transfer_chunk(file_task, chunk_id, chunk_hash)由于每个文件缺失的chunk数量不同,调度器根据不同文件的剩余chunk数量和文件优先级动态调整调度顺序,最终实现“小文件快速终结,大文件稳定推进”的效果。
3. 关键模块实现详解:分块校验、断点续传和并发调度的落地细节
3.1 清单文件与断点状态记录
MiroFish的清单文件格式设计为JSON Lines(.jsonl),每行一个JSON对象,记录一个文件的元信息。选择JSON Lines而不是一张大CSV或者单一JSON数组,主要考虑到两点:一是扫描是大批量追加写操作,JSON Lines可以一行行地append,不需要把整个清单load进内存再写;二是后续做流式读取时,可以逐行处理,不会因为单个大文件导致内存爆炸。
一个典型文件记录如下:
{"rel_path": "mirror/library/nginx/1.25.3/layer.bin", "size": 536870912, "mtime": 1707753600, "chunk_size": 4194304, "chunk_count": 128, "chunk_hashes": ["6b3f4c...", "9d1e2f..."]}断点状态文件则是另一个.mf_state文件,记录每个文件已经成功传输的chunk序号以及最后更新时间。它的更新是异步批量落盘的,每传完N个chunk或者每间隔5秒做一次flush,而不是每个chunk都写一次,否则高频小文件同步时磁盘IO会成为新瓶颈。
3.2 PATCH传输协议设计
MiroFish在目标端部署了一个轻量agent,监听一个TCP端口,接收两类请求:PUSH_CHUNK和APPLY_PATCH。
PUSH_CHUNK请求负责把某个缺失的chunk以二进制流的形式写到临时文件.mf_partial对应的偏移位置。这里必须强调一个细节:chunk在文件中的偏移位置由chunk_id * chunk_size计算,agent端写入时使用随机写(pwrite),而不是顺序拼接。好处是并发传输同一个文件的多个chunk时,各chunk之间完全不需要锁,天然支持乱序到达。
当所有chunk都到达后,客户端发送APPLY_PATCH请求,agent会把.mf_partial文件重命名为目标文件,并记录新的文件哈希。如果在rename之前发现缺失了某个chunk,agent会返回缺失清单,客户端自动重新调度缺失chunk的传输。
整个PATCH流程是幂等的。重复发送同序号chunk不会导致数据损坏,因为agent会先校验chunk哈希再写入,不匹配就直接拒绝。这是分布式传输里“at least once”语义的正确使用姿势——接收端通过校验消化重复请求。
3.3 并发数自适应:从固定值到动态反馈收敛
最初版本里,并发数是用户在配置里写死的。后来在生产环境发现一个问题:并发数设得高,千兆网络上跑得欢,但到了跨地域的低带宽链路上反而会引发TCP重传风暴。后来我加入了一个简单的拥塞控制机制,核心思路是借鉴TCP慢启动:
- 启动时并发数从4开始;
- 每成功传输20个chunk,如果平均传输时延低于上次周期的80%,则并发数加1,上限不超过配置的最大值;
- 如果出现连续3个chunk传输超时或失败,并发数立即减半,并进入30秒的“冷静期”。
这个机制上线后,跨地域同步的稳定性提升非常明显。贵州节点同步3.2TB数据的时间从原先的平均9小时缩短到了5小时左右,网络抖动触发的重试次数下降了约70%。当然这个数据依赖于具体网络环境,但机制本身的思路值得参考:固定并发数永远无法适配复杂网络,必须让工具自己根据反馈动态调整。
4. 生产环境部署实录:从配置文件到监控告警的完整落地方案
4.1 目录规划和配置结构
MiroFish采用客户端-服务端模式,客户端负责扫描和调度,服务端agent负责chunk写入和patch应用。两端共用一套Toml配置文件,结构如下:
[server] listen = "0.0.0.0" port = 9653 data_dir = "/data/mirror" quarantine_dir = "/data/quarantine" auth_token = "xxxx" [client] source_root = "/data/repos" target_root = "/data/mirror" target_endpoint = "10.20.30.40:9653" scan_threads = 8 chunk_size = 4194304 max_concurrency = 32 soft_delete = true soft_delete_retention = 7ddata_dir和target_root必须对应。quarantine_dir是软删除隔离目录,所有目标端多余文件先移到这里,而不是物理删除。soft_delete_retention控制隔离文件保留时间,到期后由单独的清理任务物理删除。
4.2 定期全量同步 + 每日增量巡检的双层调度
MiroFish设计了两种运行模式:full-sync和incremental-check。
full-sync跑全量扫描和全量差异比对,适用于初次迁移和灾备节点从零恢复。incremental-check则会读取上次同步生成的清单文件作为基准,只扫描相对路径在基准清单中出现过的文件,增量同步新出现的文件或元信息变化的文件。注意增量巡检不会处理源端已删除的文件,这步交给软删除机制去做,避免增量扫描时误删。
我们目前的调度策略是:
- 每天凌晨2点,执行
incremental-check,把当天的镜像变更推到备节点; - 每周日凌晨4点,执行一次
full-sync,修正可能累积的清单偏移和异常状态。
完整跑一次全量3.2TB同步大约需要5小时,增量巡检基本控制在30分钟以内。
4.3 监控指标与告警配置
MiroFish在客户端暴露了一个--metrics参数,监听/metrics路径输出Prometheus格式的指标。重点监控以下指标:
mirofish_scan_total:扫描过的文件数量,观察是否存在目录挂载异常导致扫描量骤降;mirofish_transfer_bytes:传输总字节数,和仓库变更量联动,如果长期为0但上游有推送,需要检查排队任务是否卡死;mirofish_chunk_retry_total:chunk重试次数,正常波动应该在万分之一以下,超过则说明网络质量问题;mirofish_soft_deleted_total:软删除文件数量,这个指标突然暴涨通常意味着源端目录配置错误。
告警规则我们设置了三条:传输任务超过2小时没有新chunk完成、chunk重试率超过0.1%、软删除文件数量超过100。这三条规则一出,基本能在故障影响扩大前发现异常。
4.4 权限最小化和密钥管理
agent端的auth_token本质上是共享密钥,但我在生产环境没有采用静态token,而是接了内部Vault服务,每24小时轮换一次。agent启动时先向Vault获取当前有效token,缓存到内存,token轮换后客户端会自动重连。这样即使某个agent的配置散落到日志里,有效期也只有24小时,风险面可控。
由于MiroFish有物理删除能力,agent进程运行用户做了严格限制,只拥有data_dir和quarantine_dir的读写权限,其余系统路径均不可写。安全审计时,这个设计是重点加分项。
5. 上线半年踩过的坑:三个让我改代码的真实故障
5.1 大文件PATCH时的“隐形丢块”问题
上线第三周,我们接到反馈:某个服务的镜像在灾备节点启动异常,疑似文件不完整。排查后发现,问题出在PATCH协议的一个并发漏洞上。当一个文件缺失大量chunk时,客户端会并发发送多个chunk到agent。但agent写入pwrite后,返回OK给客户端,客户端立即更新状态记录。问题在于agent返回OK只代表chunk写入.mf_partial成功,不代表最终rename成功。如果某个chunk在写入后、rename前因为agent进程重启而丢失,客户端状态却已经标记为“已完成”,后续不会重推,最终文件就缺了一块。
修复方案是增加APPLY_PATCH阶段的最终校验:rename前,agent需要重新计算.mf_partial的完整BLAKE3哈希,和客户端清单文件里的完整文件哈希比对,不一致则返回缺失chunk列表。这个校验只在最后一个chunk到达时触发一次,开销可控,但彻底堵住了“隐形丢块”。
提示:任何涉及文件完整性的同步工具,最终校验必须放在文件关闭、rename之前,而不是放在每个chunk写入之后。中间的窗口期就是数据损坏的高发区。
5.2 软删除误伤事件:源端目录绑定错误
另一个印象深刻的事故是软删除机制被自己人坑了一次。源端某台构建机的磁盘满了,运维哥们在清理时不小心把/data/repos误挂载成了另一个分区镜像。MiroFish扫描后发现这个分区只有几个残留文件,于是生成了大量软删除任务,把灾备节点上几千个镜像文件全部移到了隔离目录。幸运的是启用了软删除而不是硬删除,数据没有真正丢失,但从隔离目录恢复就花了一个多小时。
这个事件之后我加了两道保险:
- 在配置里增加
min_file_count参数,当目标端软删除次数超过源端扫描文件数的5%时,任务直接失败并触发告警,不执行任何删除操作; - 全量同步任务执行前,要求先手动确认总文件数变化波动不超过10%,否则任务拒绝启动。
这两条规则本质上是“防呆设计”,防止自动化工具在异常输入下做出破坏性操作。任何涉及删除的工具都应该默认带上这层保险。
5.3 扫描阶段CPU跑满导致同步延迟
有次用户反馈增量巡检明明跑了很久,传输速率却非常低。看监控发现scan_threads配置成8后,扫描过程中8个线程同时计算BLAKE3哈希,把机器CPU全部吃满,导致agent网络线程响应延迟,传输带宽上不去。
这里又是一个并发度设计问题:扫描和传输不应该共享CPU并发度预算。我后来把扫描线程池和传输协程池做了物理隔离,配置项拆成scan_threads和transfer_concurrency,并且扫描线程池默认只使用CPU核数减2的并发度,确保传输线程始终有CPU可用。修复后,全量扫描时间从1小时50分缩短到40分钟,整体同步时间反而更短了。
6. 和主流同步方案的对比:什么场景才值得自己造轮子
6.1 MiroFish vs rsync vs skopeo vs dist
很多朋友会问,同步镜像为什么不用现成的skopeo copy或者rsync,非要自己折腾。这里我整理了一个对比表,可以直观看清各自适合的场景:
| 工具 | 分块级增量 | 断点续传 | 删除保护 | 多仓库并发 | 适用场景 |
|---|---|---|---|---|---|
| rsync | 无(文件级) | 弱(临时文件机制简陋) | 无保护,--delete一把梭 | 单进程为主 | 简单目录同步、日志归档 |
| skopeo | 镜像层级 | 依赖registry实现 | 无 | 支持单镜像多路并发 | 镜像跨registry复制 |
| dist | 镜像层级 | 依赖registry实现 | 无 | 支持批量 | Linux基金会下的镜像分发工具 |
| MiroFish | 4MB chunk级 | 支持,状态落盘 | 软删除隔离机制 | 支持,协程调度 | 大型镜像仓库整体同步和灾备 |
rsync最大的问题是文件级同步,镜像仓库中一个几百MB的层文件里有几KB变化,它也得整文件重传。skopeo和dist解决的是“镜像从registry A复制到registry B”,但对于“整个仓库目录如何随时间保持一致,并且有删除保护、断点续传、状态可视”这个需求,它们并没有直接答案。
MiroFish反而更像一个“镜像仓库的文件系统同步层”,它不关心镜像内部格式,只关心文件如何保持一致,这正是很多平台团队需要的抽象层。
6.2 什么情况下不建议自己造轮子
说了半天MiroFish的优势,也必须泼点冷水。如果你的需求是单次迁移几个镜像,或者仓库体量在100GB以下,直接rsync或者skopeo就够了,自己造工具的时间和运维成本完全不划算。MiroFish的价值在下面几个条件同时满足时才成立:
- 仓库体量大(TB级以上),跨地域同步频率高;
- 有灾备恢复诉求,需要随时保持两套库数据一致;
- 对删除安全有严格要求,不能接受rsync一把梭的风险;
- 现有工具无法满足断点续传和细粒度增量。
如果团队技术栈完全偏向Go且已有成熟的对象存储,也可以考虑用rclone配合--fast-list实现类似效果。工具选型永远是场景驱动的,不要为了造轮子而造轮子。
7. 后续扩展:从单点到联邦同步
MiroFish第一版解决的是“单源到单目标”的同步问题,但实际使用中很快出现了新的需求:多套环境之间互为镜像,或者一个源同步到多个目标。我目前正在做的是联邦同步模式——支持多源多目标的镜像拓扑,核心改动是引入“同步任务”的概念,每个任务定义自己的源、目标、同步策略和调度周期,用一张任务表在数据库里编排。
联邦模式下的冲突处理比单源复杂很多,目前的设计是每个文件记录一个origin_id,同步时只允许origin_id匹配的任务覆盖该文件,其他任务只能新增不能覆盖。这个约束可以避免两个源同时修改同一个文件导致的状态抖动。
另外还在做的一个增强是“事件驱动同步”,不再完全依赖定时任务扫描,而是对接代码仓库的webhook,上游推送完成后立即触发增量同步。扫描开销比定时任务小很多,同时能显著缩短数据延迟。这块还在迭代中,等稳定后会单独写一篇实践记录。
从我个人的经验来看,镜像仓库同步工具最核心的价值不只是快,而是“可控”。你清楚每一个chunk的状态,清楚删除操作什么时候会发生,清楚网络抖动后从哪里恢复。有了这种可控性,跨地域灾备才不是一句空话。MiroFish也是在这几次事故和迭代中一点点变得可靠起来的。