ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

基于skopeo的MiroFish镜像同步工具:从原理到实践解析

2026/9/18 6:09:05 拓冰建站 浏览量
基于skopeo的MiroFish镜像同步工具:从原理到实践解析 1. MiroFish 到底解决什么问题一次“镜像搬运”的定位思考1.1 复盘一个高频场景内网部署要镜像人肉 pull 不靠谱MiroFish 这个名字我第一次看到是在一个内部工具清单里。Miro 是 mirror 的变体Fish 则明显是在比喻它像一条鱼在一堆 registry 之间游来游去。它的职责很纯粹把容器镜像从仓库 A 原样搬到仓库 B不管 A 和 B 之间隔着什么样的网络边界、权限模型还是审批流程。我们团队第一次真正需要它是在做私有化交付的时候。客户环境是完全隔离的内网服务器上只有一套最小化的 Kubernetes。按照交付清单我们要往集群里部署五六个服务每个服务还依赖两三个基础镜像Nginx、Redis、Prometheus、某个内部中间件。这时候最原始的办法是找一台能连通外网的机器手动docker pull再把镜像docker save成 tar 包拷进内网后docker load。这套流程看着能用但操作几次就会发现问题镜像列表一多人肉执行容易漏镜像版本升级后要重新导出更难受的是tar 包是“一坨”文件内网机器上没有 Docker daemon 或者空间不够load 就是一个漫长的等待过程。MiroFish 的思路完全不同。它不依赖任何守护进程直接通过 Registry API 把镜像内容从一个仓库搬到另一个仓库。你要做的只是告诉它“从哪拿、放到哪”剩下的由它自己完成。真正落地之后你会发现这种“搬运”模式比早先的 save/load 方案稳一个量级因为整个过程可以做成脚本、可以增量执行、可以留日志还能集成到 CI 流水线里。1.2 MiroFish 的定位只做“拉、验、推”三件事我习惯把 MiroFish 理解成一条极简的镜像管道。它不对镜像做任何改造不重打 tag不修改 layer不改变 manifest 结构它的职责就三件事从源仓库读取镜像清单、校验数据完整性、把内容完整写入目标仓库。很多人会问Harbor 不是自带镜像复制Replication功能吗为什么还要自己写一个这要分情况看。Harbor 的复制规则确实能实现跨实例同步适合在两个 Harbor 之间做持续复制。但现实里源仓库多种多样有可能是 Docker Hub有可能是 Quay.io有可能是某个云厂商的私有仓库还有可能是客户的临时仓库。Harbor 复制对源仓库类型的支持有限配置规则也偏重。MiroFish 不一样它把“同步清单”写成一个配置文件对这个配置可以 git 管理、可以全员 review、可以按项目维度拆分。交付时改一段配置就能跑不用去点图形界面。这条设计路线还带来一个额外好处可观测性。每条同步任务的结果都会落到日志里成功、跳过、失败一目了然。配合 Webhook 通知失败时团队能第一时间知道而不是等集群里出现ImagePullBackOff了才慌慌张张去排查。1.3 引擎选型为什么选了 skopeo 而不是 docker pull在确定实现方案时最大的取舍在于“用什么引擎来搬运镜像”。最直觉的做法是docker pulldocker tagdocker push这也是很多人第一次接触镜像同步时会想到的方案。但它有几个让我不太舒服的地方。第一它必须依赖本机 Docker daemon。daemon 不在或者版本不兼容整个流程就没法跑。第二docker pull会把镜像层解包后平铺到本地/var/lib/docker再 push 时又重新打包上传中间多了大量不必要的磁盘读写。镜像大的时候本地空间很快被撑爆。第三这套方案天然只处理当前平台架构碰到多架构 manifest list 时docker pull默认只拉当前节点对应的架构没法一次性拿到全部分支。所以我没有选这条路径而是选择 skopeo 作为搬运引擎。skopeo 是一个专门做镜像操作的开源 CLI 工具完全不需要守护进程它直接通过 HTTP 协议跟各个 Registry 通信。skopeo copy命令能够保持镜像最原始的 manifest 和 layer 结构直接从一个仓库流式复制到另一个仓库不落盘、不转码、不改变内容。搭配--digestfile参数还可以把每次同步后的 digest 记录到文件里作为下次增量判断的依据。这也是 MiroFish 这条“镜像鱼”能轻巧游动起来的核心原因。提示如果你的使用场景里有 Docker daemon 且只需要临时同步一两个镜像docker pull加docker push也能接受。但一旦同步量上来或者需要纳入自动化流程skopeo 路线几乎没有悬念。2. 关键机制拆解manifest、digest 和多架构镜像2.1 镜像不是“一个文件”而是清单加数据层的组合要把镜像同步做对首先得理解镜像在 Registry 里的真实结构。很多人习惯把镜像想成一个“大文件”实际上它是一组数据层的集合外加一份描述这些数据层的“清单文件”。打个比方镜像仓库就像一列货运火车manifest 是编组单记录着这趟车一共有多少节车厢、每一节装的是什么货、货的校验值是多少layer 则是一节节真实车厢承载着文件系统里的实际内容。当 skopeo copy 执行时它做的事情是先从源仓库拿到 manifest解析出需要哪些 layer然后逐个下载 layer 数据把它们流式上传到目标仓库最后在目标仓库写入一份新的 manifest。因为它没有把 layer 解压到本地文件系统所以整个过程比 docker pull/push 要轻快得多。这份 manifest 还分为几种格式。Docker Registry V2 规范定义了 schema 2 版本OCI 规范也定义了类似的 manifest 格式。大部分主流仓库现在都支持 schema 2 和 OCI 格式但有些老旧的私有仓库还停留在 schema 1。这类兼容性问题在同步时经常会出现后面的“常见问题”部分会专门讲。2.2 Digest增量同步和一致性校验的基石MiroFish 判断“镜像是否需要同步”用的是 digest而不是 tag。tag 只是给镜像打的一个可读标签任何时间都能被重新指向digest 则是对 manifest 内容做的 SHA256 哈希只要镜像内容哪怕一个字节发生变化digest 就会完全不同。你可以用skopeo inspect --format {{.Digest}} docker://nginx:1.25查看某个 tag 当前指向的 digest输出类似sha256:b2b0e02dd689abc7d2b8e6f12226c0b2c03451723a04a8f1e705f9c24d15a13f。增量同步的思路就建立在这个基础之上。每次同步成功后脚本把目标侧写入的 digest 记录到本地文件。下一次同步开始前先skopeo inspect源仓库拿当前 digest再跟本地文件里记录的 digest 比对。一致就跳过不一致才执行复制。这套逻辑很像软件构建里的增量编译源码没变就没必要重新打包。一致性校验是另一个隐藏价值。Registry 在存储层会校验上传的 blob 是否与声明的 digest 匹配skopeo copy 默认也会做完整性校验。如果网络传输过程中出现损坏复制任务会直接报digest mismatch而不是默默把坏数据写进目标仓库。这一点在跨网络环境下尤其重要因为它保证了“搬到内网的镜像和上游是一模一样的”。2.3 多架构镜像一个常见但容易被忽略的坑现在越来越多的官方镜像会发布多架构版本比如nginx:1.25实际上是一个 manifest listOCI Index里面同时包含 linux/amd64、linux/arm64、linux/arm/v7 等架构各自的 manifest。打包了多架构镜像之后用户在 x86 机器上拉取时Docker 会自动选择对应架构的 manifest在 ARM 机器上拉取时又会选择 ARM 分支。问题在于很多初次接触镜像同步的人会掉进一个坑同步多架构镜像时没有加--all参数结果目标仓库里的镜像只包含了当前执行机器架构对应的内容。等到 ARM 节点部署时直接报not found。这是典型的环境差异“显形”时刻。MiroFish 的默认策略是完整复制多架构内容。只要你用的是新版本 skopeo1.10 及以上skopeo copy默认就是复制完整的 manifest list而不是只复制单架构子 manifest。但如果你的使用场景确实只需要某一种架构可以通过--override-os linux --override-arch arm64强制指定这样目标仓库里就只保留单架构内容可以省下一部分存储空间。注意同步完成后强烈建议用skopeo inspect --raw docker://目标仓库镜像:tag看一眼返回结构确认是 manifest list 还是单架构 manifest。这一步只花几秒钟但能避免上线后才发现架构不对的尴尬。3. 从零搭建一套可用的镜像同步链路3.1 环境准备安装 skopeo 并准备好目标仓库先说明一下我建议的最小落地环境。你不需要一台高配服务器一台普通 4C8G 的 Linux 机器就够跑同步任务需要准备的软件只有两个skopeo 和一个兼容 OCI 的目标镜像仓库。skopeo 的安装方式按发行版来。Ubuntu/Debian 上执行sudo apt install skopeoCentOS/RHEL 上执行sudo dnf install skopeomacOS 上可以用brew install skopeo。装完后执行skopeo --version确认版本建议至少 1.10 以上这样多架构复制和省心度都更有保障。目标仓库我建议优先选 Harbor因为它自带 Web 界面、项目权限和垃圾回收机制后续管理镜像生命周期会方便很多。如果你只是想快速试验起一个本地 registry 容器也可以docker run -d -p 5000:5000 --name registry registry:2为了方便后面统一讲解我会以一个假设的私有仓库地址registry.internal.example.com为例。实际使用中你需要把它换成你们团队自己的仓库地址。目标仓库的账号密码不要写在配置明文里而是通过环境变量注入这也是后面配置文件的默认约定。3.2 配置文件设计一份可以 git 管理的同步清单MiroFish 的核心是一个 YAML 格式的配置文件。我把这个文件叫作mirror.conf里面只干一件事描述“哪个源仓库的哪些镜像要放到目标仓库的哪个路径下”。defaults: dest_registry: registry.internal.example.com retry_times: 3 concurrency: 2 auth: dest_username: deploy dest_password_env: REGISTRY_PASSWORD mirrors: - src: docker.io/library/nginx:1.25 dst: base/nginx:1.25 - src: docker.io/library/redis:7.2-alpine dst: base/redis:7.2-alpine - src: docker.io/prom/prometheus:v2.45.0 dst: prometheus/prometheus:v2.45.0 - src: quay.io/prometheus/node-exporter:v1.6.0 dst: prometheus/node-exporter:v1.6.0这里的src是源镜像的完整路径dst是目标仓库内的相对路径。这样设计有个好处不管上游路径有多长你都可以按照自己团队的命名规范重新组织目录结构。比如统一把基础设施镜像放在base/下把业务相关镜像放在各自项目名下后续给集群配置拉取规则时特别清晰。如果某些镜像需要从带认证的源仓库拉取可以再加一段aarch: quay.io: username_env: QUAY_USERNAME password_env: QUAY_PASSWORD配置文件一旦写好后我强烈建议提交到 Git 里。这样每次改动了哪些镜像谁改的什么时候改的全都有记录。交付的时候把这个配置文件和同步脚本一起发给现场工程师他们只需要配置好环境变量一条命令就能把整个内网仓库按要求填充起来。3.3 核心同步脚本批量执行的骨架有了配置文件接下来要写一个能跑起来的同步脚本。我不建议直接在命令行手敲十几条skopeo copy而是写一个简单的 Bash 脚本来读配置、并发执行、记录结果。#!/usr/bin/env bash set -euo pipefail CONF${1:-mirror.conf} DEST_REGISTRYregistry.internal.example.com DEST_USER${DEST_USER:-deploy} DEST_PASS${REGISTRY_PASSWORD:?环境变量 REGISTRY_PASSWORD 未设置} # 简单解析这里用固定规则也可改用 yq 解析 YAML declare -a SRC_LIST( docker.io/library/nginx:1.25 docker.io/library/redis:7.2-alpine docker.io/prom/prometheus:v2.45.0 quay.io/prometheus/node-exporter:v1.6.0 ) declare -a DST_LIST( base/nginx:1.25 base/redis:7.2-alpine prometheus/prometheus:v2.45.0 prometheus/node-exporter:v1.6.0 ) sync_one() { local src$1 dst$2 local digest_file.digest/$(echo ${src} | tr /: __).digest mkdir -p .digest local remote_digest remote_digest$(skopeo inspect --format {{.Digest}} docker://${src} 2/dev/null || echo ) if [[ -f $digest_file ]] [[ $remote_digest $(cat $digest_file) ]]; then echo [SKIP] ${src} 无变化 return 0 fi echo [SYNC] ${src} - ${DEST_REGISTRY}/${dst} skopeo copy \ --dest-creds ${DEST_USER}:${DEST_PASS} \ --retry-times 3 \ --digestfile $digest_file \ docker://${src} \ docker://${DEST_REGISTRY}/${dst} } for i in ${!SRC_LIST[]}; do sync_one ${SRC_LIST[$i]} ${DST_LIST[$i]} done wait echo 所有同步任务执行结束这个脚本里比较关键的有三点。第一点是--digestfile它把这次同步后的 digest 写入文件下次执行时如果 digest 相同就直接跳过这就是增量同步的核心。第二点是--retry-times 3它让 skopeo 在遇到瞬时网络错误时自动重试减少人工介入。第三点是并发控制脚本里用和wait实现了简单并发但注意不要一下并发太多任务否则目标仓库压力会很大一般 2 到 4 个并发比较稳妥。想要更严谨的定时执行可以加一个 systemd timer 或者 crontab。这里给一个最简单的 crontab 示例每天凌晨两点执行一次0 2 * * * cd /opt/mirofish ./mirror.sh mirror.conf logs/mirror.log 213.4 结果通知让同步状态主动找人同步任务如果是定时跑的日志写了没人看等于白写。我更推荐在脚本末尾加一个简单的 Webhook 通知把执行结果发给团队协作工具。Bash 里用curl就能实现核心逻辑是统计日志里的SYNC、SKIP、ERROR数量然后拼一条消息发出去。sync_count$(grep -c \[SYNC\] logs/mirror.log || true) error_count$(grep -c \[ERROR\] logs/mirror.log || true) curl -s -X POST https://hook.internal.example.com/robot \ -H Content-Type: application/json \ -d {\msgtype\:\text\,\text\:{\content\:\镜像同步完成同步 ${sync_count} 个失败 ${error_count} 个\}}这个通知的用途不是让大家每天看“一切正常”而是让异常能在第一时间浮出水面。同步失败早晚会反映到集群里的拉取异常上但如果能提前在同步阶段发现并处理业务就不会受到任何影响。4. 常见问题排查与避坑我在实际同步里踩过的坑4.1 身份认证与目标仓库连接问题先列一张速查表这些都是我实际遇到过的报错覆盖了大部分“连不上、推不进”的场景。报错信息可能原因解决思路unauthorized: authentication required目标仓库账号密码错误或没有推送权限检查环境变量注入是否正确确认账号对目标项目有 push 权限server gave HTTP response to HTTPS client目标仓库只暴露了 HTTP 端口对测试环境可临时加--dest-tls-verifyfalse生产环境推荐在仓库侧配置 TLStoken authentication required源仓库需要认证给源仓库配置--src-creds或者使用 4.4 中提到的 auth 文件方式connection refused网络不通或仓库端口没有开放telnet或nc -vz检查端口连通性再检查防火墙规则认证问题里面最容易踩的一种情况是账号密码明明正确但推送时报 401。原因是 Harbor 的机器人账户默认只对指定项目有权限你往另一个项目推送镜像时它就会拒绝。解决方式很简单要么给机器人账号加对应项目的权限要么在目标仓库里先把项目建好再推。关于 TLS 校验我在测试环境里用过--dest-tls-verifyfalse但生产环境我强烈不建议长期关闭。更好的做法是让目标仓库使用受信任的 CA 签发的证书或者把内网 CA 放到系统信任链里。这样所有客户端都不需要额外配置也避免了为了图方便而引入中间人风险。4.2 镜像结构与格式兼容性最隐蔽的一类问题格式类问题比认证问题难排查得多因为报错往往不是“不支持”这么直白而是以隐晦的方式出现。举几个我遇到的真实案例。第一个是 schema 版本不一致。某些老旧的私有仓库只支持 schema 1而 skopeo 默认按 schema 2 处理同步过程可能中途报错或者镜像推到目标仓库后无法被新版本 Docker 正常解析。碰到这类仓库一般需要设置--format v2s2强制转换。注意这只是转换了 manifest 格式镜像内容和 layer 数据没有变化。第二个是 manifest list 的处理。之前提到过复制多架构镜像要确保--all参数的行为符合预期。我遇到过一种情况明明源镜像有多个架构同步过去之后就只剩 amd64 了。排查到最后发现是当时执行的 skopeo 版本比较老默认只同步当前架构。如果你的环境里 pyskoope 版本低于 1.10在执行多架构同步时一定要显式加--all。第三个是只有 digest 没有 tag 的镜像。在依赖tag来标识镜像的流程里这类镜像是“无名氏”。同步它们需要用完整的 digest 引用例如docker://sourcesha256:xxx。建议在配置里直接把这种镜像的src写全避免手动复制到一半发现找不到 tag。经验每次同步完一个镜像我习惯立刻执行skopeo inspect --raw docker://目标仓库镜像:tag | head -c 200看一下输出。如果返回的是mediaType为application/vnd.docker.distribution.manifest.list.v2json或者 OCI index 类型说明多架构正确保留如果只是一段单架构 manifest就要引起警惕了。4.3 存储、配额与重复同步仓库膨胀的血泪教训镜像同步跑起来之后新的问题不是“同步不了”而是“仓库膨胀”。只要持续同步目标仓库里的镜像就会越积越多尤其是那些跟着上游 latest 或 nightly 构建走的镜像每次同步都会新增一批 layer老版本又不会自动清理。应对办法分两层。第一层是“入口控制”也就是在配置里尽量明确版本。能用具体版本号就不要用 latest既能保证可复现也避免仓库里堆积无意义的变动。第二层是“出口清理”定期对目标仓库执行垃圾回收。Harbor 自带垃圾回收功能它会把没有被任何 manifest 引用的孤立 layer 清掉但这需要先删除那些不再使用的 tag 或 artifact。我在实际项目里吃过一次亏Harbor 的存储一度从 80GB 涨到 200GB排查后发现有大量重复 layer是同一条同步任务反复执行产生的新版本层。加了好几套版本每套版本都由几十个 layer 组成老版本没人删。后来我们制定了两个约定镜像 tag 统一走版本号 环境名每个项目最多保留最近 5 个版本由定时任务清理过期 tag。这套约定执行后仓库体积稳定回落垃圾回收的压力也小了很多。4.4 关于认证信息保存一种更稳妥的配置方式除了命令参数传明文凭证skopeo 还支持使用 auth 文件。你可以先执行skopeo login登录目标仓库它会默认把凭证写到~/.docker/config.json之后执行skopeo copy时就不需要再传--dest-creds。这种方式的好处是脚本里不出现密码坏处是凭证跟执行用户的 shell 环境绑定切用户或跑 CI 时可能失效。我自己的习惯是在专用机器上执行同步登录一次后让凭证落盘脚本里不再显式传账号密码在 CI 流水线里则通过 Secret 环境变量注入。这两种方式二选一不要在脚本里硬编码密码也不要为了省事把所有仓库密码写进同一个文件。5. 落地经验与扩展建议5.1 内网客户端如何拉取同步后的镜像镜像同步到私有仓库只是第一步真正要让集群或开发机能用起来还得保证客户端能访问到目标仓库地址。这个环节的常见问题不在于仓库本身而在于 DNS 解析和 TLS 信任。如果内网有标准 DNS 服务直接给目标仓库地址加一条 A 记录就解决了如果没有可以临时在节点/etc/hosts里写一行静态解析。TLS 证书方面如果是自建 CA需要把 CA 证书下发到所有节点并执行update-ca-certificates。Docker 和 containerd 都会读取系统证书链所以只要系统信任了这个 CA拉取时就不会报证书错误。Kubernetes 集群里还要注意imagePullPolicy。如果镜像 tag 不变但内容更新了需要把拉取策略设为Always或者更新时使用新 tag。否则节点上的 kubelet 可能复用本地缓存不会主动去仓库重新拉取。5.2 MiroFish 还能扩展成什么从“同步”走向“分发”MiroFish 解决的是“把镜像搬到某个仓库”的问题但它带来的思路可以继续延伸。同一个配置机制稍加改造就能做成多目标分发一份镜像清单同时同步到两地或两套环境的仓库。比如灾备场景下主站点和备站点的仓库数据需要保持一致用一个循环脚本遍历目标仓库列表就能以很低的改造成本实现同步。镜像安全扫描也可以接进来。同步完成后对目标仓库的镜像跑一遍扫描器把漏洞信息汇总到报告里。这样每次上游更新镜像内网仓库里对应镜像的安全状态也会随之更新比人工定期扫描要靠谱得多。事件通知更是一个可以持续加深的点。用 Webhook 把同步结果推送到协作群只是第一版进阶做法是记录每次同步的 digest 历史和耗时形成一个简单的数据看板。它能在镜像持续变多的过程中帮助你判断同步链路有没有出现性能瓶颈以及哪些镜像同步得最频繁、最值得做本地缓存优化。5.3 团队协作中的核心建议让同步配置像代码一样被管理如果你打算在自己团队里把镜像同步流程正式跑起来我最重要的建议是把mirror.conf和同步脚本放进 Git 仓库并且要求所有变更走普通的代码 review 流程。镜像变更可能引发集群行为变化一个基础镜像从 1.25 升到 1.26可能带来行为差异这个变更应该有迹可循而不是某个人临时在服务器上手动改一行配置。在此基础上可以给同步日志加上时间戳和任务标识定期归档。运维审计时能清楚地知道某个镜像在什么时间点同步到内网同步源的 digest 是什么中间是否有失败重试。这些信息在追溯线上故障或处理合规审查时非常有用。6. 我的一点个人体会把 MiroFish 这类的镜像同步链路跑起来之后最大的感受是很多镜像问题看似是网络或环境问题实际上根源往往是流程问题。人肉 pull/push 偶尔能成但只有把同步动作变成可重复、可验证、可追溯的脚本才真正具备部署到生产环境的底气。回滚一个 bad tag、补充一批缺失的镜像、周期性清理过期版本这些动作有了脚本和配置的支撑效率完全不一样。最后分享一个小技巧在整套链路刚上线的头两周把它当成一段“养成期”。每天翻一遍同步日志注意那些报错、跳过和重试的镜像确认它们是否符合预期。等过了这段观察期你会越来越信任这套“镜像鱼”系统——它游到哪里镜像就跟到哪里。