有一次线上发布前,开发同学把构建好的镜像往公共镜像站推送,一连超时三次,发布窗口硬生生推迟了半个小时。那次之后我认真把自建docker镜像仓库从"有时间再说"调到了"本周必须落地"。
镜像仓库这层基础设施平时不显眼,可一旦出问题,就卡在发布链路最关键的位置上:镜像推不上去,后面所有环境都等着。自建一套仓库之后,存储自己控制、权限自己控制、内网流量自己扛,再也不需要掐着时间赶公共站点的高峰。这篇文章就是当时整个落地过程的技术记录,包括需求判断、选型对比、从零部署、存储原理、缓存加速和长期维护会踩的坑。适合需要私有镜像存储、内网拉取加速或离线分发的开发、运维同学参考,照着操作,半天内能跑起来一个能用于日常发布的仓库。
1. 自建之前,先回答三个问题:你的仓库到底解决什么
1.1 你是要存私有产物,还是要省公网带宽
自建docker镜像仓库最容易犯的错,是没想清楚自己到底需要"私有仓库"还是"缓存加速"。这两个需求听起来像同一件事,实际架构完全不同。
私有仓库的重点在"存"。构建产物里经常混着内部配置、密钥、业务代码,这些东西不适合放到公共镜像平台上。镜像推到公共站点,本质上就是向所有能访问它的人开放了拉取入口,很多内部产物根本不该这么暴露。你需要的是自己控制一个存储空间,只有授权的人能推能拉,同时要保留每个版本的记录,方便回滚。
缓存加速的重点在"取"。几十台节点都在拉同一个基础镜像,第一台从公网拉完后,后面的节点最好直接从内网仓库拿,不要让流量反复穿透公网链路。少了这一层,每次部署都要跟公网波动较劲,出口带宽也蹭蹭往上涨。
这两个目标指向不同的技术方案:私有仓库用标准Registry加认证就行;缓存加速需要开启Registry的proxy缓存模式,或者在节点侧配置镜像加速地址。
1.2 是一个人用,还是一个团队几十个人用
自建仓库的复杂度和用户数量是强相关的。自己一个人用,能push、能pull、数据不丢,基本就够了。团队用起来就完全是另一回事:镜像仓库怎么命名、谁能写谁能读、上了线的镜像谁改过、到底有哪些项目在占磁盘,这些都会从"无所谓"变成"必须管"。
我给过不少朋友同一个建议:如果只是个人实验、CI管道自用,先别上重型方案;如果明知道接下来有二三十人要用,一开始就按多人协作的标准规划。后面再迁移往往比一开始就搭好麻烦得多。
1.3 你的环境允不允许走公网
这一点最容易被忽略。有的环境是彻底隔离的,节点无法访问公网,你不用自建仓库就得靠U盘和压缩包来回拷贝;有的环境公网可达,仓库可以定期从上游同步镜像,节点只需要连内网。
我见过一个团队,一开始觉得自己能连公网,直接在每台机器上配置了公共镜像源凑合,结果上线那天公网链路抖动,所有新节点都拉不到基础镜像。后来才补了一个内网迁移方案。自建仓库这事儿,最怕的就是"我现在能连公网所以不急",真到急的时候往往已经晚了。
当你把上面三个问题过完一遍,"要不要建、建什么形态、按多大规模建"基本就清晰了。
2. 选型对比:轻量Registry与全功能Harbor,谁更值得装
2.1 轻量Registry:一个二进制组件撑起整个仓库
私有仓库领域最经典的方案就是官方开源的Registry 2.x。它本质上就是一个实现镜像分发协议的组件,没有UI、没有用户管理界面,配置用一个YAML文件解决。数据目录就是一个文件夹,挂载到宿主机某条路径上,拷贝这个文件夹就是备份全部镜像。
轻量Registry的部署成本低得惊人:一条docker run命令就能启动,资源占用很小,几百MB内存就能跑得很流畅。它把"私有镜像存储"这件最核心的事做得很纯粹,多余的功能一概不要。个人开发者、自动化CI管道、中小团队的日常发布,用这个方案完全撑得住。
它的短板也明显:没有Web界面,你没法在浏览器里浏览哪些镜像存在;没有项目隔离和细粒度权限,只能靠htpasswd做最简单的账号认证;镜像复制、配额管理这类功能一概没有。如果团队规模变大,这些短板会慢慢变成痛点。
2.2 全功能Harbor:镜像仓库里的"全家桶"
当一个小团队开始长出"哪个项目是谁传的、谁能拉这个项目"这类问题时,就该考虑企业级仓库组件Harbor了。
Harbor是构建在镜像分发基础之上的一整套平台,包含Web管理界面、项目隔离、基于角色的访问控制、镜像复制同步、垃圾回收机制等能力。你可以把镜像按项目划分,给不同成员分配不同权限,还能配置跨站点的副本同步规则,把镜像自动推到另一个机房的仓库里。
代价是资源开销和运维复杂度。Harbor用容器方式部署,核心组件至少包括入口、核心服务、镜像仓库服务、任务服务、数据库、缓存等多个服务,内存建议准备4G以上。升级、备份、排障的复杂度都会上一个大台阶。
我个人判断是:一个产品团队或平台性质的团队,长期需要多人管理镜像、留审计记录,直接上Harbor是值的;个人或小团队只是为了内部发布,Harbor的很多能力你用不上,反而是负担。
2.3 一张表看明白关键差异
| 对比维度 | 轻量Registry | 全功能Harbor |
|---|---|---|
| 部署成本 | 一条命令,几十KB配置 | 多组件编排,需要规划域名、数据库、缓存 |
| 资源占用 | 几百MB内存足够 | 建议4G内存以上 |
| 用户认证 | htpasswd基础认证 | 本地用户、LDAP等多种方式 |
| 权限控制 | 无,登录即全量访问 | 项目级RBAC,细粒度读写分离 |
| Web界面 | 无 | 完整管理界面 |
| 镜像复制 | 不支持 | 支持规则化同步 |
| 垃圾回收 | 手动执行命令 | 界面操作并可视化管理 |
| 适合场景 | 个人/CI/中小团队 | 多人协作/企业交付/跨机房同步 |
2.4 我的选型结论
如果你现在还拿不准,就直接走这条路:个人实验或中小团队先上轻量Registry,配上HTTPS和htpasswd认证,稳定跑几个月没问题。等真的出现"镜像是谁传的、谁能拉这个项目"这类权限问题时,再迁移到Harbor。
反过来,如果你明知道这个仓库最终要服务几十人,还涉及多个项目组,那就别省这一步,直接上Harbor。我见过不少中途迁移的例子,数据搬运本身不复杂,真正麻烦的是客户端那边所有脚本里的仓库地址、账号密码全要换一遍,升级成本远超你一开始多花的那半天。
3. 从零搭建一个有用户名密码的HTTPS镜像仓库
3.1 环境准备:目录规划要一次到位
我习惯把仓库数据放在独立分区,避免和系统盘混在一起。准备阶段先建好三块目录:证书目录、认证目录、数据目录。
mkdir -p /opt/registry/certs mkdir -p /opt/registry/auth mkdir -p /opt/registry/data域名规划也提前想好,比如用registry.example.com,端口用标准的443。端口这件事上踩过坑的人不少:如果用非标准端口,后面所有客户端配置都要带着端口写,比如registry.example.com:5000,时间一长很容易写错。能用443就用443。
3.2 生成自签证书并让Docker信任
Docker客户端有一个默认安全策略:对于非localhost的镜像仓库,强制要求TLS加密。如果你直接用一个裸HTTP地址,客户端会报一个很经典的错误:http: server gave HTTP response to HTTPS client。
要解决这个问题有两个方向:一是配置自签证书并让Docker客户端信任它,二是在Docker守护进程配置里把仓库地址加入insecure-registries。我非常推荐第一种,虽然多一步,但链路是全程加密的,后面各种工具访问仓库都不会因为"跳过TLS校验"出幺蛾子。
生成自签证书:
openssl req -newkey rsa:2048 -nodes \ -keyout /opt/registry/certs/registry.example.com.key \ -x509 -days 365 \ -out /opt/registry/certs/registry.example.com.crt \ -subj "/CN=registry.example.com"这里有个非常关键的细节:自签证书的CN要和访问仓库用的域名完全一致。如果你证书里写的是registry.example.com,但客户端访问的是IP或者另一个域名,TLS校验照样失败。
生成证书后,把它放到Docker客户端的信任目录里。路径格式是/etc/docker/certs.d/<仓库域名>:<端口>/ca.crt,然后重启Docker:
mkdir -p /etc/docker/certs.d/registry.example.com:443 cp /opt/registry/certs/registry.example.com.crt /etc/docker/certs.d/registry.example.com:443/ca.crt systemctl restart docker3.3 用htpasswd创建账号并启动Registry
我用htpasswd方式做基础认证。可以借助Registry容器自带的htpasswd工具生成密码文件,注意加-B参数使用bcrypt加密,Registry对这种密码格式兼容性最好:
docker run --rm --entrypoint htpasswd registry:2 -Bbn deploy '8-HOP-eH4FwJ' > /opt/registry/auth/htpasswd上面这行会生成一个用户名为deploy、密码为指定值的密码文件。如果后续要添加用户,继续执行同样的命令,注意用>>追加而不是覆盖。
启动Registry容器的完整命令如下:
docker run -d --name registry --restart=always \ -p 443:443 \ -v /opt/registry/data:/var/lib/registry \ -v /opt/registry/certs:/certs \ -v /opt/registry/auth:/auth \ -e REGISTRY_HTTP_TLS_CERTIFICATE=/certs/registry.example.com.crt \ -e REGISTRY_HTTP_TLS_KEY=/certs/registry.example.com.key \ -e "REGISTRY_AUTH=htpasswd" \ -e "REGISTRY_AUTH_HTPASSWD_REALM=Registry Realm" \ -e REGISTRY_AUTH_HTPASSWD_PATH=/auth/htpasswd \ -e REGISTRY_HTTP_SECRET=my-random-secret-string \ -e REGISTRY_STORAGE_DELETE_ENABLED=true \ registry:2几个环境变量的作用我多说一句:
REGISTRY_HTTP_SECRET是服务端会话密钥,用来防止跨站请求伪造,生产环境一定设置一个足够随机的字符串。REGISTRY_STORAGE_DELETE_ENABLED=true必须开,否则后面你通过API删除镜像时会发现删除接口直接不可用。--restart=always保证机器重启后仓库自动起来,不然每次都要手动拉起容器。
3.4 客户端完整验证:login、push、pull
先在客户端登录:
docker login registry.example.com输入用户名和密码后,打一个标签推到仓库里:
docker tag nginx:1.24 registry.example.com/ops/nginx:1.24 docker push registry.example.com/ops/nginx:1.24拉取验证:
docker pull registry.example.com/ops/nginx:1.24最后用接口确认仓库里的镜像列表:
curl -u deploy:8-HOP-eH4FwJ https://registry.example.com/v2/_catalog能返回JSON格式的镜像列表,就说明整套链路通了。
3.5 部署期最常见的一组报错对照
| 报错信息 | 问题根源 | 处理方式 |
|---|---|---|
http: server gave HTTP response to HTTPS client | 客户端强制走TLS但仓库是HTTP,或证书信任没配好 | 补证书到/etc/docker/certs.d目录,或临时加insecure-registries |
x509: certificate signed by unknown authority | 自签证书没被客户端信任 | 把证书放到Docker信任目录并重启Docker |
x509: certificate is valid for ..., not ... | 证书CN和访问域名不一致 | 重新生成匹配域名的证书,或让DNS解析指向仓库IP |
unauthorized: authentication required | 未登录或密码错误 | 检查账号密码文件、重新docker login |
这里我想特别强调:insecure-registries是应急方案,不是日常方案。它会让客户端跳过TLS校验,数据在传输过程中相当于明文,内网环境还能忍,一旦仓库要跨网络访问,这就是安全隐患。能走证书信任就走证书信任。
4. 仓库里的数据长什么样:理解分层存储、Tag与清理逻辑
4.1 Registry的存储目录结构
很多人自建了仓库却不知道数据到底存放在哪,出了问题只能整个目录删掉重来。理解Registry的数据布局,对备份和清理都很有帮助。
在Registry容器内部,数据根目录默认为/var/lib/registry。我们挂载宿主机目录后,在这个目录里会看到docker/registry/v2这样的路径。下面主要分两类东西:
blobs目录:保存镜像的所有真实数据块,按schema、sha256等目录层级存放,以内容的哈希值作为寻址标识。相同内容的层在这里只存一份。repositories目录:保存的是"指针",记录某个仓库下面有哪些Tag,每个Tag又指向哪个Manifest,Manifest再指向哪些层。
打个生活化的比方:blobs是图书馆里所有实际的书,repositories是书架上的索引卡片。删除一个Tag只是抽掉一张索引卡,书还在库房里,所以磁盘空间不会立刻释放。
4.2 一次push操作背后发生了什么
当你执行docker push时,客户端并不是一通盲传。它会先询问仓库"这个镜像的哪些块你已经有?",仓库通过Manifest和Layer的哈希值判断,本地已经存在的块直接跳过,只传输缺失的块。
这也是镜像仓库天然能去重的基础:不同的镜像如果基础镜像层相同,这些层在blobs目录里只有一份。举个例子,ops/nginx和ops/web两个镜像都基于同一个基础镜像构建,基础镜像只需存一次。
这个特性带来的实用效果是:在同一个Registry内,多项目多Tag总体上不会像你想象中那样迅速膨胀,因为大量公共基础层是共享的。但如果你每次构建时都生成毫无复用的新层,比如把整个构建上下文打进去,那磁盘空间就会涨得很快。
4.3 删除镜像的正确姿势:DELETE API加垃圾回收
有个常见的误解:用docker rmi删了本地镜像,若干清理客户端没用,仓库里的镜像还在。仓库里的镜像要删除,正确路径是走API。
先取得Manifest的摘要信息:
curl -u deploy:8-HOP-eH4FwJ \ -H "Accept: application/vnd.docker.distribution.manifest.v2+json" \ -I https://registry.example.com/v2/ops/nginx/manifests/1.24响应头里的Docker-Content-Digest就是Manifest的摘要,然后执行删除:
curl -u deploy:8-HOP-eH4FwJ \ -X DELETE https://registry.example.com/v2/ops/nginx/manifests/<digest>到这里只删掉了"索引卡片",实际blobs还没释放。还需要进入容器执行垃圾回收:
docker exec registry bin/registry garbage-collect /etc/docker/registry/config.ymlGC会扫描所有存活的Manifest引用,把不再被任何Tag引用的blob从磁盘上清理掉。这里有一个重要的经验:GC执行期间尽量让仓库进入低写入状态,别在GC的同时大量push镜像。并发写入和GC同时进行,有可能把还在上传中的层当作孤立数据清理掉,导致推送中断甚至数据损坏。
4.4 磁盘占用要常看,别等满了才处理
镜像仓库的磁盘增长是缓慢但持续的过程。我建议在运维脚本里至少每天统计一次仓库数据目录的大小:
du -sh /opt/registry/data df -h /opt/registry/data当磁盘占用超过分区的70%时就应该启动一轮清理:先整理出那些已经无人引用的旧Tag,删除后跑GC。配合前面讲的删除流程,定期执行,仓库才不会越用越臃肿。
5. 把仓库变成内网加速器:缓存、同步与离线分发
5.1 开启pull-through cache,让仓库自动缓存上游镜像
内网集群最头疼的问题不是私有镜像推不上去,而是所有人都要从公网拉同一个基础镜像。与其让每台节点都走一遍公网,不如让仓库承担缓存角色。
Registry本身支持proxy缓存模式。在配置文件中指定上游地址,仓库收到本地没有的镜像拉取请求时,会先到上游拉取并缓存一份,后续再有节点拉同一个镜像,直接命中本地。
写一个独立的缓存仓库配置文件:
version: 0.1 log: fields: service: registry storage: filesystem: rootdirectory: /var/lib/registry cache: blobdescriptor: inmemory proxy: remoteurl: https://registry-1.docker.io启动时挂载这份配置,并用不同的数据目录,避免和私有仓库混在一起。我强烈建议把私有仓库和缓存仓库分开部署,哪怕都在同一台机器上。两个角色混在一个仓库目录里,日常清理时会非常痛苦,因为你根本分不清哪些数据是私有镜像、哪些只是临时缓存。
5.2 节点侧也可以配置镜像加速地址
除了Registry的proxy模式,Docker守护进程本身支持registry-mirrors配置。你可以在每台节点的/etc/docker/daemon.json里指定一个缓存地址:
{ "registry-mirrors": ["https://registry.example.com"] }节点拉取公共镜像时,会先访问这个地址,由它决定是否回源。配置完成后要重启Docker才能生效。
这里容易困惑的是:到底用Registry的proxy,还是用daemon的registry-mirrors?我的经验是,如果只是一个内网大仓库给所有人共享,配置Registry的proxy让所有节点直接指向它,管理更集中;如果想针对每类节点区分配置,再考虑daemon侧的方式。
5.3 离线环境的镜像搬运:save、load与skopeo
有些环境完全无法访问公网,但又不是彻底的空白环境,这时候需要用离线包搬运镜像。最基础的做法是docker save打成tar包,拷进内网后docker load加载。
docker pull nginx:1.24 docker save nginx:1.24 -o nginx-1.24.tar在目标机器上:
docker load -i nginx-1.24.tar手动操作一个两个镜像还好,镜像一多就容易漏。更推荐用镜像同步工具处理,比如skopeo这类工具,它能直接从一个Registry复制到另一个Registry,不依赖本地Docker守护进程,还可以只复制需要的架构和平台:
skopeo copy docker://registry.example.com/ops/nginx:1.24 docker://new-registry.example.com/ops/nginx:1.24离线环境下推荐把它做成一个清单文件,批量同步,避免一个个手工敲命令。
5.4 多机房同步:让镜像自动跑到目标仓库
如果存在多个机房、多套集群,每一套都手动推送一遍镜像是不现实的。Harbor这类带复制能力的产品在这里价值最明显:配置好复制规则,指定源项目和目标仓库,镜像一推入源仓库,后台任务服务会自动同步到目标端。
轻量Registry下也有曲线实现思路:写一个定时任务,定期用skopeo把新增Tag同步过去。虽然没有界面那么优雅,但胜在轻量、可控。
6. 长期维护避坑:备份、监控与我最常处理的几个事故
6.1 备份方案:别等到数据丢了才想备份
轻量Registry的备份方式非常朴素:数据目录整体备份即可。因为所有镜像数据都在一个文件系统目录里,用rsync同步到另一台机器或NAS都能胜任:
rsync -av --delete /opt/registry/data/ backup-server:/backup/registry-data/要注意的是,如果仓库正在被大量写入,直接拷贝数据目录可能拿到不一致的快照。稳妥做法是先在低峰期执行一次同步,或者在备份前短暂停掉容器。停机备份虽然有一小段时间不可用,但对仓库这种低频变更的基础设施来说,完全可以接受。
Harbor类平台的备份要复杂一些,除了镜像数据本身,还得备份数据库里的元数据、用户、项目配置。这类平台的备份建议直接参照官方文档做好定期演练,别等出了问题才翻开文档。
6.2 监控指标:磁盘是最重要的生死线
镜像仓库最值得监控的就是磁盘占用率。它不像CPU、内存那样波动明显,但一旦写满,所有节点都无法推送和拉取,而且清理也要花时间。建议对仓库数据目录做磁盘告警,阈值设在70%开始提醒,80%强制响应。
仓库的访问日志默认输出到容器标准输出,用docker logs registry就能看到。我平时会重点关注两类日志:一类是认证失败记录,可能是有人在爆破或密码泄漏;另一类是429和超时状态,说明并发压力上来了,需要考虑扩容或者调整配置。
6.3 几个典型事故的处理复盘
我自己的仓库跑起来之后,踩过的坑基本集中在下面几个方向。
第一个是证书过期。自签证书设了一年后,某天所有节点突然开始报TLS错误,排查到最后才发现是证书到期了。从那以后我在日历里挂了一个提前两周的提醒,用openssl x509 -enddate -noout -in 证书文件定期检查也确实很有必要。
第二个是容器数据目录没挂全。有人部署Registry时只映射了端口,没有把容器内/var/lib/registry挂到宿主机,重启容器后所有镜像全部消失。这个坑特别隐蔽,因为运行期间一切正常,直到某次机器重启或者容器重建才爆发。部署完之后,一定要验证数据目录确实是宿主机上的持久目录。
第三个是GC和高并发写入的冲突。我之前在一次业务高峰期执行垃圾回收,结果有镜像层被误清理,部分节点拉取时出现找不到blob的报错。现在我的清理流程固定为:先停掉相关项目的推送任务,再执行GC,最后恢复。宁可牺牲一小段可用时间,也不冒数据不一致的险。
6.4 把维护动作沉淀成日常规范
跑了小半年之后,我把仓库相关的维护动作整理成了一张最简单的检查清单:每周看一眼磁盘占用、每月清理一轮无用Tag并执行GC、每季度做一次数据恢复演练、证书到期前两周提醒全部使用方。把这些动作写进团队的部署文档里,仓库就不再是一个"偶尔出问题才想起来"的系统,而是一个有稳定运维节奏的基础组件。
回头再想那次因为公共镜像站超时导致的发布事故,其实解决路径并不复杂:一个可靠的私有镜像仓库、一套完整的认证体系、一份固定的清理和备份节奏。这套东西对业务的价值,平时看不见,但在每一次发布顺利按时完成的时候,都在默默起作用。