第一次知道 MiroFish 这个项目,是在公司内部的技术分享角落。当时我们正被“本地改代码、容器里跑服务”这个开发模式折腾得够呛——每次改完代码都要手动 docker cp 一遍,或者敲一条 rsync,改得频繁的时候一天能敲几十次,手一抖还会把配置文件覆盖错。后来有人甩过来一个链接,说“试试 MiroFish,监听加镜像同步一条龙”。我用了大概一个下午,就把原来那套“rsync + watchman + 一堆自嗨脚本”的组合方案换掉了,从此本地目录和容器/远程开发机之间的文件同步再也没让我操过心。
MiroFish 本质上是一个面向开发场景的轻量级文件镜像同步工具。它做的核心事情就三件:实时监听本地文件变化、按规则过滤后增量同步到目标端、用内容哈希和原子写保证同步结果可靠。它解决了单目录到容器/远程主机反复手动复制、配置漂移、以及“监听+同步组合脚本”里事件丢失和死循环的典型问题。适合经常做容器本地开发、远程开发、边缘设备部署,或者需要在多台机器之间同步配置文件的人参考。
我后续会从它的设计思路、安装部署、核心参数、实现原理、完整实操到常见问题排查,把整个项目拆开讲一遍。内容会尽量具体到可以直接“抄作业”,也会把我在实际使用中踩过的坑一并写出来。
1. 为什么会有 MiroFish:开发同步的痛点与设计思路
1.1 镜像同步到底在解决什么问题
先说一个很常见的场景。你在本地用 VSCode 写业务代码,但服务跑在 Docker 容器里,或者跑在一台远程开发机上。改完代码,IDE 里的一切都很正常,但容器里的进程用的还是旧文件。于是你只能手动执行 docker cp 或者 rsync,运气不好还要在多个终端窗口之间反复切来切去。
这类操作的痛点不只是“麻烦”。手动同步意味着你随时可能忘记同步,然后对着一个和本地不一致的环境调半天 bug,最后一查发现是代码没拷过去。更隐蔽的是配置文件漂移:比如你在本地改了 .env 或者 nginx.conf,但没有同步到目标机器,服务行为变得不可预期,谁都不知道哪个版本是“真”的。
MiroFish 解决的就是“让一个目录在多个环境之间保持一致”这件事。它把自己做成一个常驻的镜像层:你本地的 src 目录是“主”,容器里的 /app/src 是“镜像”,主端一有变化,镜像端跟着变。加上内容哈希对比之后,它还能避免很多无意义的重复写入,而不是像某些工具那样不管文件有没有变,先整个复制过去再说。
1.2 为什么不用 rsync + watchman 组合方案
很多人会问:既然 Linux 上有 inotify,有 watchman,同步用 rsync 不好吗?是不是重复造轮子?
说实话,rsync 本身足够可靠,watchman 的事件监听也很好,但“组合起来”和“好用”之间还差着一大堆工程细节。我最初自己搭的方案大概长这样:watchman 监听目录变化,触发一个 shell 脚本,脚本里再调用 rsync -avz --delete。初看没问题,实际跑起来全是坑:
- watchman 触发的事件非常频繁,每次改动可能触发几十个事件,脚本被反复拉起,目标端磁盘写入压力很大。
- 如果同步的目标路径恰好也在监听范围内,就会形成“同步产生新事件、新事件再次触发同步”的死循环。
- rename 操作在 inotify 里往往被拆成 delete + create 两个事件,脚本处理不当会出现“文件短暂消失”的情况。
- 没有统一的日志和状态回溯,同步失败了,很难判断是哪个文件、哪一步出了问题。
MiroFish 的做法是把“监听、过滤、合并事件、内容比对、增量同步、冲突处理、回环检测”全部收敛到一个进程里。它不是简单的“在 rsync 外面包了一层壳”,而是把事件流转换成同步任务队列,用批量和去重的方式解决事件风暴,再用哈希和原子写保证落盘结果可靠。
1.3 MiroFish 的核心设计原则
用了一段时间之后,我总结出这个工具的几个核心原则,这也是它和普通同步脚本拉开差距的地方。
第一是“单一二进制、零依赖部署”。MiroFish 用 Go 编写,编译出来就是一个静态链接的可执行文件,丢到 Linux、macOS、Windows 上都能跑。不需要装 Python 环境,不需要 npm install,更不需要额外装 rsync 或者 inotify-tools。
第二是“实时与批量结合”。它既不是实时到“每个事件都同步”,也不是定时批量同步。而是先把事件收进一个队列,在极短的时间窗口内做合并和去重,再按批落盘。这个设计同时照顾到了及时性和磁盘 IO 效率。
第三是“安全同步优先”。默认开启原子写,先写临时文件再 rename,避免目标端出现半个文件。默认开启 loop-protection 回环检测,从机制上防止“A 同步到 B、B 又触发 A”的经典死循环。另外支持 dry-run 模式,可以先看它要做什么,再真正执行。
2. 环境准备与安装部署
2.1 获取 MiroFish 的几种方式
MiroFish 的安装方式非常友好,我实际用过三种:直接下载 release 二进制、源码编译、Docker 容器运行。
如果目标机器是 Linux amd64,可以直接从 release 页面下载对应平台的压缩包,解压后把 mirofish 放到 /usr/local/bin 下:
wget https://example.com/releases/mirofish-linux-amd64.tar.gz tar -xzf mirofish-linux-amd64.tar.gz sudo mv mirofish /usr/local/bin/ mirofish version如果机器上有 Go 环境,也可以自己编译,这种方式适合想改源码或者验证最新提交的情况:
git clone https://example.com/mirofish.git cd mirofish make build ./bin/mirofish version如果你想在容器里跑,镜像本身的体积很小,基础镜像用的是 alpine,整个工具加运行时大概不到 20MB。但要注意容器方式需要把宿主机目录以 volume 方式挂载进去,并且要对 inotify 的实例数上限做适当调优,否则监听大量文件时会报 “too many open files” 之类的错误。
2.2 初始化工作目录与配置骨架
安装完成后,先建一个工作目录。我个人习惯放在 ~/.mirofish 下,把配置文件和日志统一管理:
mkdir -p ~/.mirofish cd ~/.mirofish mirofish initinit 命令会生成一个默认的 mirofish.yaml,里面带了注释、示例规则和一个默认的日志目录。生成完之后的目录结构大概是这样:
~/.mirofish/ ├── mirofish.yaml ├── logs/ └── state/ └── mirofish.dbstate 目录里存的是同步状态数据库,用来记录每个文件上次同步的哈希和元数据。这也是 MiroFish 能做增量同步的基础——它知道哪些文件已经同步过、哪些内容变了,不需要每次全量扫描整个目录。这里有个小经验:state 目录最好放在本地磁盘上,不要放到网络盘或者容器 overlay 文件系统里,否则状态读写本身会成为瓶颈。
2.3 配置文件的整体结构
MiroFish 的配置格式是 YAML,整体结构围绕“同步组”展开。每一组定义了一个 source(源目录)、target(目标目录)以及这组同步的规则。下面是一份最小可用配置:
project: demo sync: - name: code source: ./src target: /app/src watch: true excludes: - "**/node_modules/**" - "**/.git/**" delete: true conflict: newest log: level: info file: ./logs/mirofish.log每个字段的含义我后面会逐个展开。这里先强调两个关键点:一是 source 和 target 都必须是绝对路径,或者是相对于配置文件所在目录的路径,不建议用含义不明确的相对路径;二是 target 可以是一个本地路径,也可以是 rsync 风格的远程路径,比如 user@host:/path/to/dir,后者依赖 SSH 通道,首次连接需要配置免密登录。
3. 关键参数与规则配置详解
3.1 source/target 与多路同步
配置里的 sync 是一个数组,这意味着你可以同时定义多组同步关系。比如我之前的一个项目,需要同时把本地代码同步到容器工作目录、把本地 nginx 配置同步到另一台机器、把本地脚本同步到边缘设备,三组规则放在同一个配置里,一个 MiroFish 进程全部搞定。
sync: - name: code-to-container source: /home/user/project/src target: /home/user/project/.devcontainer/app/src watch: true - name: nginx-conf source: /home/user/project/deploy/nginx target: user@192.168.1.20:/etc/nginx/conf.d watch: true debounce: 3000 - name: edge-scripts source: /home/user/project/scripts target: admin@edge-device:/opt/scripts watch: false interval: 60注意第三组规则我设置的是 watch: false 和 interval: 60。这是一个很实用的模式:对于变化不频繁、但要求最终一致的目录,比如部署脚本,没必要用实时监听,每 60 秒轮询一次就够了,既省资源又不会因为频繁同步产生额外噪音。实时监听和定时轮询可以混用,这是很多人忽略的配置技巧。
3.2 过滤规则的写法与常见坑
excludes 字段支持 glob 模式,并且是相对于 source 根目录的。以下是一些常见写法:
excludes: - "**/node_modules/**" - "**/.git/**" - "**/*.log" - "cache/**" - "!.env"前四项都是排除模式,最后一项带了感叹号,表示“重新包含”。MiroFish 的规则是“先匹配排除,再匹配包含”,如果你先用**/*排除了所有文件,再用!.env想把 .env 加回来,是无效的。正确的做法是控制排除的粒度,不要用过于宽泛的排除规则,再去搞特例。
另一个常见的坑是“没有排除目标目录本身”。如果 target 目录在 source 目录内部,比如 source 是 /home/user/project,target 是 /home/user/project/dist,那么同步产生的 dist 目录变化会再次触发监听事件,如果回环检测没有生效,就会导致无限循环。最稳妥的做法是在 excludes 里明确把 target 目录的相对路径排除掉:
excludes: - "dist/**" - "**/.git/**"即使 MiroFish 有 loop-protection 机制,我也建议在规则层面把目标目录排除掉,双保险永远比单保险可靠。
3.3 debounce、worker、retry 这些参数怎么定
debounce 是事件合并的时间窗口,单位毫秒。它的含义是:监听器收到文件变化事件后,不立即同步,而是等待一段时间。如果这段时间内又有新事件进来,就重新计时。这样可以避免“编辑器保存一次文件触发多次事件”带来的重复同步。
我实际使用下来,本地代码同步建议用 1200 到 2000 毫秒,既能保证足够低的延迟,又能有效合并事件风暴。如果同步的是 Docker 容器内的开发目录,可以设置 1000 毫秒左右,因为容器内文件变化频率一般不高。如果是远程同步,建议设置在 2000 毫秒以上,因为网络延迟会放大同步开销,宁可稍微慢一点,也不要频繁建立连接。
worker 是并发执行同步任务的线程数,默认 4。它的上限取决于目标端的 IO 能力和网络带宽。一般来说,worker 数设为 CPU 核数的 1.5 到 2 倍即可,不建议无脑调大。我之前在一台 4 核机器上把 worker 调到 16,结果小文件同步速度确实上去了,但目标端磁盘 IO 被打满,反而拖慢了整体性能。
retry 是失败重试次数,默认 3。对于远程同步场景,网络抖动是常态,所以我通常把 retry 调到 5,并且把 retry-interval 设置为 2 秒,给网络恢复留出时间。
下面是我常用的一个参考基准表:
| 参数 | 本地同步建议值 | 远程同步建议值 | 说明 |
|---|---|---|---|
| debounce | 1500ms | 2500ms | 事件合并窗口 |
| worker | CPU 核数 x2 | min(CPU 核数, 4) | 并发任务数 |
| retry | 3 | 5 | 失败重试次数 |
| retry-interval | 1s | 3s | 重试间隔 |
| delete | true | 建议 true | 是否同步删除操作 |
| conflict | newest | newest | 冲突处理策略 |
这里特别说明一下 retry 和 delete 的关系。如果目标端文件被删除,而源端还在,MiroFish 会按规则把它重新同步回来。但如果你不小心删除了源文件,delete: true 会让目标端也删除。所以在关键目录上,我一般会同时开启safety: backup选项,让目标端被删除的文件先进备份目录,而不是直接 SQL 掉。
3.4 冲突处理与原子写
所谓冲突,指同一个文件在源端和目标端都被修改了,并且内容不一致。MiroFish 支持三种冲突策略:
- newest:按修改时间取最新版本,这是默认策略。
- largest:按文件大小取最大版本,适合日志收集、不断追加的场景。
- keep-both:把目标端的冲突文件改名为
filename.conflict-<timestamp>保留下来,再把源端文件同步过去。
我之前在同步配置文件时遇到过一个问题:本地改了 nginx.conf,结果远程机器上也有其他人改了同一个文件,两边内容冲突。newest 策略直接把对方的改动覆盖了,后来我改用 keep-both,虽然会留下一个 .conflict 文件,但至少不会丢数据。所以如果同步的目录可能有多方修改,我强烈建议用 keep-both。
原子写是一个容易被忽略但很重要的细节。MiroFish 默认先把同步内容写入一个临时文件(比如.filename.mirofish.tmp),写入完成并校验大小/哈希之后,再通过 rename 覆盖目标文件。这样做的原因是:如果直接写目标文件,写入过程中进程崩溃或网络断开,目标端就会留下一个截断的半成品文件,某些应用读到这个文件可能直接崩溃。原子写能保证目标端任何时候看到的都是“完整旧文件”或“完整新文件”,不存在中间状态。
4. 核心实现原理拆解
4.1 事件监听:跨平台怎么做
MiroFish 的监听层在不同操作系统上用的底层机制不一样:Linux 上用的是 inotify,macOS 上用的是 FSEvents,Windows 上用的是 ReadDirectoryChangesW。这些机制的差异很大,想自己完全搞定,需要不少适配工作。
inotify 是 Linux 内核提供的文件系统事件通知机制,它的优点是细粒度、低延迟,但缺点是监听的是“目录”而不是“递归树”。所以 MiroFish 在启动时会递归扫描所有子目录,并为每个目录注册 inotify watch。文件数量一多,inotify watch 的数量就会非常大,如果你在容器里跑 MiroFish,需要留意系统限制:
# 查看当前 inotify 实例和 watch 数量限制 sysctl fs.inotify.max_user_instances sysctl fs.inotify.max_user_watches如果 watch 数量不够,可以临时调大:
sudo sysctl -w fs.inotify.max_user_watches=524288 sudo sysctl -w fs.inotify.max_user_instances=1024macOS 的 FSEvents 走的是另一套思路:它不提供逐目录的 watch,而是让应用指定一个根路径,内核返回某个时间窗口内发生变化的路径集合。好处是不需要维护海量 watch,坏处是事件粒度可能不够细,需要自己对比目录快照来找出变更文件。MiroFish 在 macOS 上会定期生成目录快照并进行 diff,监听延迟会比 Linux 略微高一点,但整体可用性很好。
Windows 上的 ReadDirectoryChangesW 我用的不多,但大致机制和 inotify 类似,MiroFish 暴露出来的行为在三个平台上基本一致,配置和同步规则完全通用,底层差异被封装在 watcher 层里,这也是用 Go 写这个项目的好处之一。
4.2 从事件到落盘:队列合并与去重
MiroFish 收到事件后并不会立刻同步,而是先送入一个事件处理队列。队列里做两件事:合并和去重。
合并很好理解:如果 200ms 内同一个文件被修改了 10 次,队列只需要保留最后一个“待同步”标记。去重则是指:如果目录本身已经因为子目录创建而标记为“需要扫描”,那么子目录内单个文件的事件就可以忽略,不再重复处理。
这个设计的动机主要是避免“写放大”。假设你在 IDE 里执行了一次代码格式化,可能会一次性触发上百个文件的写入事件。如果每个事件都触发一次完整的同步流程,目标端的磁盘 IO 会被直接打满。MiroFish 的做法是:事件进入队列后,经过 debounce 时间窗口的等待,工作人员一次性从队列里取出所有待处理路径,按目录分组合并,再并发执行实际的同步任务。
我把这个机制理解为“早晚要干,不如一起干”。它不会降低最终的同步完成率,但能显著减少目标端的写入次数和网络请求数量。
4.3 内容哈希与真正的“变更判断”
监听事件告诉我们文件名/路径变了,但文件内容到底变没变,是另一回事。很多人会直接用 mtime 和文件大小来判断,但这两个指标都不可靠。比如:touch 一个文件,mtime 变了,内容没变;编辑器保存文件后,大小一样,mtime 变了,内容可能没变或变了。
MiroFish 用的是内容哈希。它会读取文件内容,计算 xxhash64 哈希值,并和 state 数据库里上次同步记录的哈希值对比。只有哈希不一致,才真正执行同步。这个设计避免了一种很常见的悲剧:容器里有个进程在持续 touch 某个文件,导致 mtime 一直变化,如果只看 mtime,MiroFish 会把同一个文件反复同步,产生大量的无意义写入。
每次同步前都全量读文件、计算哈希,对大文件来说成本也不低。所以 MiroFish 做了一个优化:先比较文件大小,如果大小一致且文件超过 64MB,会只读取文件头部 4KB、尾部 4KB 和中间 4KB 各算一次哈希,拼成一个综合签名;只有签名不一致,才继续全量哈希。这样可以大幅度降低大文件的哈希开销。
4.4 死循环防护逻辑
同步工具最怕的事情是“A 同步到 B,B 的变化又触发 A 的监听”。MiroFish 的 loop-protection 机制做了三层防护。
第一层:事件标记。同步写目标文件时,写入线程会带上一个特殊的内部标记,监听器识别到目标是本工具写入的路径后,会直接忽略该事件。
第二层:状态对比。即使第一层漏掉了某些事件,比如 rename 操作产生的事件标记丢失,监听器会读取事件文件的哈希,和 state 数据库里“刚同步过”的哈希对比。如果一致,说明内容没有实际变化,忽略。
第三层:路径排除。这一层其实是给用户自己用的。如果配置里没有排除 target 目录,工具在启动时会输出一条 warning,提示存在回环风险。你在配置里主动排除 target 之后,这个风险就从机制上消除了。
在实际使用中,我见过不少死循环问题的根源不是工具失效,而是用户把 target 目录放在 source 的监控范围内,同时关闭了 loop-protection。所以我建议:不要关闭 loop-protection,即使你觉得你的目录结构不可能产生循环。
5. 实操:把本地代码实时同步到 Docker 容器
5.1 场景与前置条件
下面用一个我在日常开发中最常用的场景,完整演示一边 MiroFish 的配置和运行流程。
场景设定:本地有一个 Node.js 项目,源码在 /home/user/work/demo-app/src,服务跑在一个 Docker 容器里,容器内工作目录是 /app。以往每次改代码都要手动 docker cp,现在希望 src 目录一旦有变化,自动同步到容器的 /app/src 里。
前置条件:宿主机上有 Docker,Docker 容器已经创建并处于运行状态。我们需要先确认容器内路径可写,并且宿主机到容器的工作目录具备挂载条件。我这里使用的方案是把目标目录挂载为宿主机的一个本地路径,MiroFish 直接同步到这个挂载目录。
5.2 步骤一:准备配置文件
在 ~/.mirofish/mirofish.yaml 里写入如下配置:
project: demo-app sync: - name: code-to-container source: /home/user/work/demo-app/src target: /home/user/work/demo-app/.container-mount/app/src watch: true debounce: 1500 excludes: - "**/node_modules/**" - "**/.git/**" - "**/*.log" delete: true conflict: newest atomic: true log: level: info file: /home/user/.mirofish/logs/mirofish.log配置里 source 是本地源码,target 是容器卷挂载点对应的宿主机路径。docker run 时用-v /home/user/work/demo-app/.container-mount/app:/app把该目录挂载进去,这样 MiroFish 同步到宿主机挂载点,容器内就能直接看到。
5.3 步骤二:dry-run 检查与初次全量同步
配置写好后,先不要直接启动,用 dry-run 模式看一遍它打算做什么:
mirofish sync --dry-run --config ~/.mirofish/mirofish.yaml输出会列出所有将被同步的文件,以及每个文件是新建、更新还是删除。这一步非常重要,可以及时看出 excludes 是否生效、是否会把不该同步的文件带过去。我第一次用的时候就是因为没跑 dry-run,差点把 node_modules 整个同步到容器里,幸好输出里看到了海量 node_modules 条目,及时拦截。
确认无误后,再跑一次全量同步:
mirofish sync --config ~/.mirofish/mirofish.yaml全量同步会把 source 下所有符合条件的文件首次复制到 target,并且建立 state 数据库。对于大项目,全量同步可能需要几分钟,期间可以观察日志确认进度。
5.4 步骤三:启动守护进程并验证
全量同步完成后,就可以启动守护进程进入实时监听模式:
mirofish daemon --config ~/.mirofish/mirofish.yaml然后随便在 src 目录下改一个文件、新建一个文件、删除一个文件,几秒后再去容器里看对应路径:
docker exec -it <container-id> ls -la /app/src正常情况下,改动会在 1 到 2 秒内出现在容器里。如果想实时看日志,可以另开一个终端执行:
tail -f /home/user/.mirofish/logs/mirofish.log在日志里能看到类似这样的记录:
INFO[2025-01-15T10:32:01+08:00] file changed, add to queue path=/home/user/work/demo-app/src/index.js INFO[2025-01-15T10:32:03+08:00] synced path=/home/user/work/demo-app/.container-mount/app/src/index.js bytes=1024 hash=8f3a2b1c...看到 “synced” 且 bytes/hash 正确,就说明实时同步链路已经通了。
5.5 步骤四:落地为系统服务
本地开发机如果常开,可以把它注册成 systemd 服务,保证开机自启、崩溃自动拉起。下面是我用的一份 service 单元文件:
[Unit] Description=MiroFish sync daemon After=network.target docker.service [Service] Type=simple User=yourname ExecStart=/usr/local/bin/mirofish daemon --config /home/yourname/.mirofish/mirofish.yaml Restart=always RestartSec=5 [Install] WantedBy=multi-user.target保存到 /etc/systemd/system/mirofish.service 后:
sudo systemctl daemon-reload sudo systemctl enable --now mirofish sudo systemctl status mirofishmacOS 上则可以用 launchd 做类似的事情,Windows 上用任务计划程序或者 NSSM 都行。MiroFish 本身是前台进程,不依赖终端窗口,所以做成系统服务非常自然。
6. 常见问题与排查技巧实录
6.1 监听不生效:先查边界条件
最让人头疼的问题就是:配置都正确,但改动文件后就是不触发同步。我遇到过的情形可以归纳为三类:
第一类是目录监听边界问题。inotify 默认不递归子目录,如果 MiroFish 在启动时因为某个子目录没有权限,导致该目录未被注册到 watch 列表,那么这个目录下的任何变化都监听不到。排查方法是看启动日志里有没有 “failed to watch directory” 的 warning。
第二类是符号链接问题。MiroFish 默认不跟随符号链接,也不监听符号链接目标目录的变化。如果你的 source 目录里有 symlink 指向外部目录,同步时默认会创建同名 symlink,而不是复制目标内容。如果你希望跟随,需要在配置里显式开启follow-symlinks: true。
第三类是挂载边界问题。在 Docker 容器里,如果 source 目录本身是一个 overlay 挂载点,某些文件系统事件可能不会向上传递。这种场景最稳妥的方式是确认 source 和 target 至少有一侧在普通文件系统上,不要用网络文件系统作为 source 目录。
6.2 事件风暴与 CPU 飙高
事件风暴的特征是:MiroFish 进程 CPU 占用率很高,目标端 disk IO 持续打满,但实际上根本没有几个文件真正变了。常见触发源是构建工具:npm run build、webpack 编译、vite 热更新都会在短时间内生成大量中间文件,这些文件会触发海量监听事件。
解决办法分成两层。第一层是配置层面:把构建输出目录、临时目录、日志目录全部加进 excludes。第二层是运行层面:如果某个目录的事件量实在太大,建议直接把该目录排除出监听范围,或者对那一组同步规则单独调高 debounce。
我一般会在项目里建一个 .mirofishignore 文件,类似 .gitignore 的思路,把构建产物、缓存目录全部忽略掉。这样既不影响同步核心代码,也能避免事件风暴。注意 MiroFish 的排除规则是相对于 source 根目录的,如果你的构建输出在 src/build,直接写build/**就行。
6.3 死循环:A 同步 B、B 又触发 A
死循环的症状非常明显:日志里两个方向都在不断出现 “synced”,目标端和源端的修改时间来回刷新。
我实际遇到过一次,原因是有两组同步规则,第一组把 ./shared 同步到 ./consumer/shared,第二组又把 ./consumer 同步到远程目录。第一组的 target 恰好是第二组的 source 的一部分,于是第一组的同步动作被第二组监听到,第二组同步回到远程目录,远程目录又通过另一条链路触发回本地。
这种问题用 MiroFish 的 loop-protection 其实已经能挡住大部分,但前提是不要手动关闭它。同时我建议把所有同步组的 target 路径都检查一遍,确认没有任何 target 位于其他同步组的 source 范围内。如果确实有,就在对应组里增加 exclude:
- name: consumer-sync source: /home/user/work/consumer target: user@remote:/home/user/work/consumer excludes: - "shared/**"6.4 大文件同步中断
同步几个 GB 的数据库备份文件时,如果网络抖动,同步可能失败。MiroFish 的 retry 机制会重新尝试,但如果文件是二进制且写入没有做成原子操作,目标端可能留下临时文件残留。
我的经验是:对于超大文件,不要依赖实时同步,先手动做一次 rsync 预同步,确保目标端已经有完整版本。之后 MiroFish 的事件监听会在文件变化时自动增量同步,因为哈希一致的情况下它不会重复复制大文件。
如果你确实需要通过 MiroFish 同步大文件,也要确保 atomic 写开启。开启后,目标端会先写.filename.mirofish.tmp,写完成再 rename 成正式文件,即使同步中断,也不会破坏已存在的正式文件。另外,同步完成后留意一下目标目录下的 .tmp 残留,可以在配置里加一条清理规则或者定期手动清理。
6.5 权限与所有权问题
远程同步时,SSH 用户对目标目录需要有写权限,这个层面如果配置不对,会在日志里直接看到 permission denied。但还有一个隐蔽问题是文件所有者和组权限不一致:如果你用 root 账户同步,目标文件 owner 会变成 root,应用进程可能因此无法读取。
Docker 容器场景更明显:容器内进程通常以特定 UID 运行,比如 node 用户是 1000。如果你在宿主机上以普通用户同步文件,挂载到容器里的文件 owner 也是该普通用户的 UID,可能和容器内进程的 UID 对不上,导致“文件能看见但打不开”。
遇到这个问题,可以在配置里指定同步之后文件的目标 UID/GID:
sync: - name: code-to-container source: /home/user/work/demo-app/src target: /home/user/work/demo-app/.container-mount/app/src owner: uid: 1000 gid: 1000这个功能在本地同步到挂载目录时非常实用,能避免很多容器内权限报错。
6.6 排查工具与日志分析
MiroFish 的日志默认是结构化 JSON,每一行都包含时间、级别、事件类型、同步路径、哈希值。排查问题时我喜欢用 grep 和 jq 组合:
# 查看最近的同步错误 tail -n 1000 ~/.mirofish/logs/mirofish.log | jq 'select(.level=="ERROR")' # 查看某个路径的同步历史 grep "config.yaml" ~/.mirofish/logs/mirofish.log | tail -n 50另外,MiroFish 还内置了一个 inspect 子命令,可以查看当前监听目录的状态,包括 watch 数量、队列长度、最近事件时间:
mirofish inspect --config ~/.mirofish/mirofish.yaml队列长度如果持续积压,说明同步速度跟不上事件产生速度,优先检查 debounce 是不是设置得太小、worker 是不是太少、目标端 IO 是否正常。
下面把最常见的几类问题整理成一个速查表:
| 症状 | 可能原因 | 排查与解决 |
|---|---|---|
| 文件改了不同步 | 目录未监听/符号链接/权限问题 | 查看启动日志是否有 watch 失败,确认路径权限 |
| 同步后目标文件是旧的 | 哈希比对错误/缓存 | 确认 state 数据库一致,用 mirofish sync --full 强制全量 |
| CPU 飙升 | 事件风暴 | 加 exclude,调大 debounce,确认是否有构建目录 |
| 目标端出现 .tmp 文件 | 同步中断 | 开启 atomic,检查网络,清理残留 |
| 两个方向反复同步 | 形成了同步环 | 检查 target 是否在 source 范围内,开启 loop-protection |
| 容器内文件权限异常 | UID/GID 不匹配 | 配置 owner uid/gid,或调整容器挂载参数 |
7. 一些个人经验与更深的体会
用 MiroFish 跑了半年之后,我想分享一个更深层的体会:这类工具的核心不是“快”,而是“稳”。事件监听、增量同步、哈希比对这些东西,本质上都是为了解决一个问题——让你完全不需要关心文件是怎么过去的,只需要相信最终状态是一致的。但“相信”是需要工程保障的,而不是一句口号。
我在最初使用的时候,犯过一个低级错误:本地目录和目标目录都放在同一个磁盘分区上,然后我配置了 source 没有排除 target,结果 MiroFish 同步的目标路径又在监听范围内。虽然 loop-protection 把它挡住了,但日志里的 warning 提示让我意识到,这类工具的防线往往不是设计出来的,而是被用户的各种极端用法逼出来的。
后来我养成了一个习惯:每次配置新规则,先跑 dry-run,再启动 daemon,然后故意制造一次文件变化,去目标端确认结果。这一步“人为验证”比任何配置检查都可靠。另外,给每一组同步规则起一个清晰明了的 name,排障时看日志能省掉大量时间。日志里每条记录都会带上 sync group 的 name,比如 code-to-container 和 edge-scripts 混在一起也能一眼分辨。
如果你用它来管理生产环境相关目录,我建议在关键规则上开启 backup 选项,把目标端被覆盖/删除的文件保留一份历史版本。这个操作不会占用多少资源,但能在关键时刻救你一命。个人使用中,MiroFish 最让我满意的是它的“可预期性”——我知道什么条件下它会同步、什么条件下不会,这种确定性让日常开发再也不必为文件同步这件事分心。