- 引言 —— 把实例扩到三台那天的错觉
- 镜像仓库的本质不是存储,而是命名
- zot 的选择 —— 磁盘上就是 OCI 镜像布局
- 横向扩展是分片,不是 HA
- 两种模式各自放弃了什么
- boltdb 实际上把实例数锁死为一个
- 镜像同步中 digest 固定被破坏的条件
- 「轻量单一二进制」的实际体积
- 参考资料
引言 —— 把实例扩到三台那天的错觉
假设你觉得所有部署都吊在一个内部镜像仓库上很不安,于是把实例扩到了三台。前面放上负载均衡,挂好健康检查,仪表盘上亮起三盏绿灯。
可是把其中一台摘掉的那一刻,对某些仓库的 docker pull 全部失败了。另外两台明明好端端的。
这不是 bug,这就是设计。你打开的不是高可用,而是分片。而这个区分在挑选镜像仓库时,比性能数字重要得多,可它在大多数文档里被笼统地塞进了 “scale out” 这一个词。
本文以 zot 的官方文档为依据来点破这个区分。这不是劝你选 zot 或者别选 zot,而是在讲:文档里有哪些东西是必须确认之后才能往下走的。
镜像仓库的本质不是存储,而是命名
把镜像仓库理解成「镜像文件服务器」,就会读错它的扩展设计。仓库实际管理的是两类名字。
第一类是 digest。形如 sha256:abc...,是 blob 内容的哈希。内容相同名字就相同,名字相同内容就相同。这里没有冲突,也不需要缓存失效。
第二类是标签。像 myapp:latest 这种,由人来起,随时可以改成指向另一个 digest。这里才是状态,分布式系统里所有难的部分都在这儿。
理解了这个结构,扩展设计的约束自然就浮出来了。blob 复制到哪里、复制几份都无所谓。但对于 myapp:latest 此刻指向什么,各个实例必须给出同一个答案。所以镜像仓库的集群化最终会收敛到一个问题上:怎样把标签更新串行化。
zot 的选择 —— 磁盘上就是 OCI 镜像布局
zot 的设计前提写在文档的第一行。磁盘上原样采用 OCI Image Layout Specification,网络上原样采用 OCI Distribution Specification。用文档的说法就是:任何遵循 Distribution Specification 的客户端都能从 zot 仓库读写。
这在实务上为什么重要?因为它意味着把磁盘目录原样拷走,那就是一个有效的镜像仓库。迁移和备份的性质完全变了。如果是把镜像结构装在自有元数据数据库里的仓库,你就得连那个数据库一起搬,版本不一致还搬不动。
存储后端支持本地文件系统(包括通过 NFS 或 FUSE 挂载的远程文件系统)、AWS S3、GCS 和 Azure Blob Storage。文档写明的一个例外是,由于路径分隔符的问题,S3 在 Windows 上不受支持。
横向扩展是分片,不是 HA
现在进入正题。把 zot 的 scale-out 文档原样读一遍是这样的。
请求进来时,实例会对仓库路径做哈希,查哈希表,找出负责该仓库的实例。哈希函数用的是 SipHash,理由是抗碰撞和抗原像攻击。如果自己不是负责方,就把请求转发给负责的实例,自己以代理身份把响应带回来。
配置长这样。
"cluster": {
"members": [
"zot-server1:9000",
"zot-server2:9000",
"zot-server3:9000"
],
"hashKey": "loremipsumdolors",
"tls": {
"cacert": "test/data/ca.crt"
}
}
请留意 members 是一份静态列表。没有共识协议,没有 leader 选举,也没有成员关系的 gossip。因为所有实例持有相同的列表和相同的 hashKey,所以它们做同样的计算、得到同样的答案。这正是这个设计优雅的地方:它把标签更新的串行化问题,置换成了以仓库为单位的所有权,从而把共识整个去掉了。
代价文档里原样写着。这套配置不会自愈,一个实例挂掉之后,映射到该实例的那些仓库会一直受影响,直到集群重启。原文在与高可用作对比时写道:实例或存储下线会影响服务可用性。
也就是说,三台是把吞吐量分成了三份,而不是把故障承受能力乘以三。
两种模式各自放弃了什么
zot 的文档把横向扩展分成两种形态。
仅计算扩展是所有实例共享一套 S3 兼容存储和一份分布式缓存(Redis 或 DynamoDB)。不使用本地缓存。因为存储是共享的,实例就接近于无状态的计算节点。
计算兼存储扩展则是每个实例各自持有本地存储。作为代价,文档明确写着这种形态下不支持 UI。
而且两种形态共通的一点是:在横向扩展部署中CVE 扫描会被禁用,信任扩展(trust extension)因为要求共享目录而不受支持。
这份清单为什么重要?因为组织自建镜像仓库的理由,通常恰恰就是「想在仓库里直接看到漏洞扫描结果」。如果打开扩展的那一刻这个功能就关掉了,那扫描就得挪到 CI 流水线那一侧。这是决定扩展之前就该知道的事实,等出了故障才知道就麻烦了。
boltdb 实际上把实例数锁死为一个
打开去重之后,zot 会让相同内容的 blob 在磁盘上只存一份,由多个 manifest 去引用它。在本地文件系统上是用硬链接实现的。它在启动时强制配置的状态:开着就把已有 blob 去重,关着就把云存储上的 blob 还原成原始状态。
这个去重需要一份记录「哪个 digest 的实体在哪里」的元数据,装这份元数据的就是缓存驱动。
- boltdb —— 本地存储的默认值。以文件形式嵌入 zot 的根目录。文档中明确写着它不提供并发写访问,因此多个 zot 实例无法共享它。
- DynamoDB —— 用于远程存储。需要 AWS 凭据和 IAM 权限。
- Redis —— 远程存储的另一个选项。支持单实例与集群配置,可以设置键前缀。
这里是个无声的陷阱。保持默认值起两台,再挂上同一个 NFS 卷,两边就会各自抱着自己的 boltdb 去动同一个目录。这是文档明令禁止的组合,可光看配置文件是看不出来的。请记住:要用共享存储做扩展,得先换掉缓存驱动。
镜像同步中 digest 固定被破坏的条件
zot 可以对上游仓库做镜像同步,有两种模式。周期轮询每隔 pollInterval 扫一遍上游,把符合条件的镜像复制到本地;按需模式则在请求进来时拉取并缓存。两种模式也可以一起用。
{
"urls": ["https://registry1:5000"],
"onDemand": false,
"pollInterval": "6h",
"content": [{ "prefix": "/repo", "destination": "/local" }]
}
文档点出的限制在实务里尤其扎人。
第一,Docker Hub 不支持目录列举,所以只能用按需模式。周期轮询得先拿到「都有些什么」的清单才能工作,而这一步做不到。
第二,把 Docker 镜像转换成 OCI 格式,会破坏按 digest 固定的拉取和签名校验。 因为转换改变了 manifest 的字节,哈希也就变了。要保留原始 digest,就得打开 preserveDigest,而这要求把 docker2s2 加进 http.compat。
如果你的部署流水线是按 digest 而不是按标签来固定镜像的(这么做是对的),那引入镜像同步的那一刻就必须确认这个条件。
「轻量单一二进制」的实际体积
zot 的介绍语里总跟着一句「无依赖、静态构建的单一二进制」。前半句是事实:不需要额外的运行时或数据库进程,也不需要提权就能运行。
不过「轻量」这个说法,最好确认过再用。GitHub 发布版 v2.1.20(2026 年 8 月 4 日公开)的 Linux 产物体积如下。
| 产物 | 体积 |
|---|---|
| zot-linux-amd64 | 约 215MiB |
| zot-linux-amd64-minimal | 约 78MiB |
| zot-linux-arm64 | 约 200MiB |
| zot-linux-arm64-minimal | 约 73MiB |
full 构建之所以大,是因为它把漏洞扫描和 Web UI 在内的所有功能都塞进了一个二进制。文档在说明提供 full 与 minimal 两个变体时,把 minimal 描述为「为了安全而削减了功能」的构建。
这里要判断的不是体积本身,而是里面装了什么。把扫描器放进仓库的二进制里,那要更新扫描器就得重启仓库。不想要这种耦合,就该用 minimal,把扫描挪到外面去。无论选哪一边,都应该是有意识的选择。
参考资料
현재 단락 (1/65)
假设你觉得所有部署都吊在一个内部镜像仓库上很不安,于是把实例扩到了三台。前面放上负载均衡,挂好健康检查,仪表盘上亮起三盏绿灯。