5489 字
27 分钟
私有镜像仓库:Registry 与 Harbor 部署实战
2026-11-15

上一篇 《Dockerfile 入门》 讲了怎么把自己的应用打包成镜像,打包完紧接着一个问题:镜像放哪?推 Docker Hub 要公开(私有仓库数量还有限),传文件用 docker save 又笨重。这篇就解决这个问题:在自己的服务器上搭一个私有镜像仓库。主线是官方 Registry——十分钟部署、配好认证和 HTTPS、走通推送拉取全流程;再讲什么时候该升级到企业级的 Harbor。

为什么需要私有仓库#

先把动机说清楚,私有仓库解决的是这四件事:

  • 内网拉取快。服务器都在自己手里,镜像从局域网/同机房的仓库拉,比每次去 Docker Hub 绕一圈快得多,批量部署时差距尤其明显。
  • 镜像不用公开。自己写的业务镜像里可能带着代码和配置,推到公共仓库等于把家底亮出去;私有仓库只对自己人开放。
  • 躲开 Docker Hub 的限流。匿名用户从 Docker Hub 拉镜像有速率限制(按 IP 计数),CI 里频繁拉取很容易撞上 toomanyrequests;公共基础镜像可以先同步到自己的仓库当缓存。
  • 可用性自己说了算。公共仓库抽风、网络不通的时候,自己的仓库不受影响;离线环境(内网机房)更是只能靠私有仓库供镜像。

一句话:镜像是资产,资产要存在自己手里。

选型:Registry 还是 Harbor#

Docker 生态里搭私有仓库主流就两个选择,先看清区别再动手:

维度官方 Registry(registry:2)Harbor
本质单容器,就是仓库本体一整套平台:Registry + 门户 + 数据库 + 扫描器
资源占用内存几十 MB,单机随便跑起步建议 2 核 4G、40G 磁盘
用户与权限只有 htpasswd 账号密码,无分级权限多用户、项目级权限、机器人账号、LDAP/OIDC 对接
Web 界面无(需另配第三方 UI)自带完整管理界面
镜像扫描无内置 Trivy,可拦截有高危漏洞的镜像被拉走
仓库复制无支持跨仓库定时/手动同步
垃圾回收命令行手动执行界面里可配定时 GC
部署维护一个 Compose 服务离线安装包一键装,但组件多、升级要走流程

选型线很简单:

  • 个人或小团队、就一两台服务器、只要「能推能拉」——先用 Registry 起步,绝大多数人到这一步就够了。
  • 出现这些信号再考虑 Harbor:多人协作需要分权限、合规要求镜像必须过漏洞扫描、多个机房/多套环境要互相复制镜像、需要审计谁拉了什么。

本文主线是 Registry,Harbor 在文末单独讲升级路径。

第一步:部署 Registry#

目录规划(沿用本系列的习惯,一个服务一个目录):

Terminal window
mkdir -p /opt/registry/{data,auth,certs}
cd /opt/registry

docker-compose.yml:

services:
registry:
image: registry:2
container_name: registry
restart: unless-stopped
ports:
- "127.0.0.1:5000:5000"
volumes:
- ./data:/var/lib/registry
environment:
- TZ=Asia/Shanghai

两个说明:

  • 镜像数据存在 /var/lib/registry,挂到宿主机的 ./data,备份和迁移时直接打包这个目录(备份方法见 《数据卷与备份恢复》)。
  • 端口先只绑 127.0.0.1——这一步只是把仓库跑起来验证,认证和 HTTPS 都还没配,没认证的仓库绝不能直接暴露公网,原因下一节细说。

启动并验证:

Terminal window
docker compose up -d
curl http://127.0.0.1:5000/v2/
# 返回 {} 说明仓库 API 活着
curl http://127.0.0.1:5000/v2/_catalog
# 返回 {"repositories":[]},现在还是空仓库

/v2/ 是仓库的健康检查口,返回空 JSON 就是通了。

补一句配置方式的说明,方便后面看懂:Registry 的全部配置既可以写成一个 config.yml 挂到容器里的 /etc/docker/registry/config.yml,也可以像本文这样用环境变量表达——环境变量的命名规则是把配置路径大写、层级之间用下划线连接,比如配置里的 auth.htpasswd.path 对应 REGISTRY_AUTH_HTPASSWD_PATH。个人部署用环境变量写在 Compose 里最直观,配置项多了再换成 config.yml 文件,两者不要混着写同一项,会互相覆盖搞不清以谁为准。

第二步:加认证(htpasswd)#

没有认证的仓库意味着:任何知道地址的人都能往里推镜像、把你的镜像全拉走,甚至用同名镜像覆盖你的版本投毒。所以认证是私有仓库的底线配置,不是可选项。

Registry 支持的认证方式里最简单的是 htpasswd 文件。用官方文档推荐的方式生成(借 httpd 镜像里的 htpasswd 工具,-B 表示用 bcrypt 加密,这点很重要,老式的 MD5/SHA1 都不要用):

Terminal window
cd /opt/registry
docker run --rm --entrypoint htpasswd httpd:2 \
-Bbn admin '换成你的强密码' > auth/htpasswd

参数拆解:-B bcrypt 加密、-b 从命令行读密码、-n 输出到标准输出而不是写文件。生成的 auth/htpasswd 内容形如 admin:$2y$05$... 一行。再加第二个用户就去掉 -n 换成追加写入,或者重新生成时把多个用户写进同一个文件:

Terminal window
# 追加一个只用于拉取的账号(第一次生成文件时不要带 -c 以外的覆盖选项,这里用 >> 追加)
docker run --rm --entrypoint htpasswd httpd:2 \
-Bbn puller '另一个密码' >> auth/htpasswd

然后给 Compose 加上认证配置:

services:
registry:
image: registry:2
container_name: registry
restart: unless-stopped
ports:
- "127.0.0.1:5000:5000"
volumes:
- ./data:/var/lib/registry
- ./auth:/auth:ro
environment:
- TZ=Asia/Shanghai
- REGISTRY_AUTH=htpasswd
- REGISTRY_AUTH_HTPASSWD_REALM=Private Registry
- REGISTRY_AUTH_HTPASSWD_PATH=/auth/htpasswd

注意 REGISTRY_AUTH_HTPASSWD_PATH 写的是容器内路径 /auth/htpasswd,和挂载点对应。重建后验证:

Terminal window
docker compose up -d
curl http://127.0.0.1:5000/v2/
# 现在应返回 401 Unauthorized,说明认证生效
curl -u admin:你的密码 http://127.0.0.1:5000/v2/
# 带账号密码返回 {} 才算配对
WARNING

htpasswd 认证只有「有账号/没账号」两档,没有只读/可写之分——任何一个有效账号都能推能删。如果你的场景需要「CI 只能推、生产机只能拉」的分级权限,htpasswd 已经不够用,这正是升级 Harbor 的典型信号(Harbor 有项目成员角色和只读机器人账号)。

第三步:加 HTTPS#

Docker 客户端默认只信任 HTTPS 仓库。给仓库上 HTTPS 有两条路,按你的条件二选一。

路线 A:有域名,挂正式证书(推荐)#

假设域名 registry.example.com 已解析到这台机,证书文件(比如从 Let’s Encrypt 申请的)放在 ./certs 下,Compose 里加三行环境变量并把证书目录挂进去:

ports:
- "5000:5000"
volumes:
- ./data:/var/lib/registry
- ./auth:/auth:ro
- ./certs:/certs:ro
environment:
- REGISTRY_HTTP_TLS_CERTIFICATE=/certs/fullchain.pem
- REGISTRY_HTTP_TLS_KEY=/certs/privkey.pem

证书怎么申请、Nginx/Caddy 怎么配合,《Nginx 反向代理与自动 HTTPS》 已经写过。除了让 Registry 自己终止 TLS,另一个常见做法是 Registry 仍跑 HTTP 只听本机,前面套一层 Nginx/Caddy 反代来上 HTTPS——两种都行,反代路线的好处是证书续期和网站共用一套,仓库本身不用动。

路线 B:纯内网自签,客户端配 insecure-registries#

没有域名、仓库只在内网用,可以自签证书甚至直接 HTTP,但必须在每一台要访问仓库的 Docker 客户端上把仓库地址声明为不安全仓库。编辑客户端的 /etc/docker/daemon.json:

{
"insecure-registries": ["192.168.1.10:5000"]
}

改完重启 Docker(systemctl restart docker)才生效。代价要心里有数:

  • 这个地址从此走明文 HTTP(或不验证书),同网段有人抓包就能看到镜像内容和登录凭据的 Base64(等于明文)。
  • 每台新机器都要记得加这一行,漏了就报证书/协议错误(排障一节会讲到这个经典报错)。

所以 insecure-registries 只适合可信内网临时用,只要仓库地址会出内网,就必须上正规 HTTPS。

第四步:全流程实战(推一个镜像上去)#

仓库地址下面统一用 registry.example.com:5000 举例(走的路线 A,HTTPS 已配好;内网场景换成你的 IP:5000)。

1. 登录

Terminal window
docker login registry.example.com:5000
# 输入刚才 htpasswd 里建的账号密码
# Login Succeeded 才算成功

登录凭据会存进 ~/.docker/config.json(Base64 编码,不是加密,保护好这个文件)。两个卫生习惯:服务器上多人共用账号时,用完执行 docker logout registry.example.com:5000 把凭据清掉;在 CI 里则用只给这次任务的临时账号密码通过环境变量注入,不要把 config.json 打进任何镜像或提交进任何仓库——它进镜像等于把仓库钥匙送给所有能拉到镜像的人。凭据一旦怀疑泄露,处理方式是回 htpasswd 重新生成该用户的密码,旧密码立即失效,这也是 htpasswd 方案运维上为数不多的便利之处。

2. 给镜像打仓库标签

镜像要推到哪个仓库,是由它的名字决定的——名字前缀必须是仓库地址。拿 Dockerfile 篇 打出来的 myapp:1.0 举例:

Terminal window
docker tag myapp:1.0 registry.example.com:5000/myapp:1.0

想分项目管理可以再加一层目录,如 registry.example.com:5000/team/myapp:1.0,Registry 对这种多级路径原生支持。

3. 推送

Terminal window
docker push registry.example.com:5000/myapp:1.0

输出里每一层后面的 Pushed / Layer already exists 说明分层上传在工作:已存在的层不会重复传,这是镜像分层设计带来的天然增量。

4. 删掉本地,重新拉回来验证

Terminal window
docker rmi registry.example.com:5000/myapp:1.0
docker pull registry.example.com:5000/myapp:1.0
docker images | grep myapp

能重新拉回来并正常 docker run,整条链路才算真的通了。再补两个日常会用到的点:

  • 同一个镜像推多个版本就是重复打不同标签再 push,如 myapp:1.0、myapp:1.1、myapp:latest 可以同时指向构建产物;latest 只是个普通标签,不会自动指向最新版,养成习惯是版本号标签必推、latest 顺手带上,拉取时尽量写明版本号,别养成只写 latest 的习惯,出了问题都不知道自己跑的是哪一版。
  • 给别的机器用:其他服务器上先 docker login 一次,之后在 Compose 里把 image: 写成带仓库前缀的完整名字,docker compose pull 就会自动从私有仓库拉——私有仓库和 Compose 的配合不需要任何额外配置,地址写对就行。

也可以用 API 查仓库内容:

Terminal window
curl -u admin:你的密码 https://registry.example.com:5000/v2/_catalog
curl -u admin:你的密码 https://registry.example.com:5000/v2/myapp/tags/list

进阶:把 Registry 当 Docker Hub 的本地缓存#

私有仓库还有一个很实用的模式:pull-through cache(拉取透传缓存)。再起一个 Registry 实例,专门当 Docker Hub 的本地镜像站——客户端把镜像源指向它,它本地没有就去 Docker Hub 拉一份、存下来,下次直接本地供。前面说的「躲开 Docker Hub 限流、内网加速」靠的就是它。配置只需要三行环境变量:

hub-cache:
image: registry:2
container_name: hub-cache
restart: unless-stopped
ports:
- "127.0.0.1:5001:5000"
volumes:
- ./cache-data:/var/lib/registry
environment:
- REGISTRY_PROXY_REMOTEURL=https://registry-1.docker.io

客户端在 daemon.json 里把这个地址设为镜像加速地址(注意:这是给 Docker 守护进程用的 registry-mirrors,和前面客户端信任自签仓库的 insecure-registries 是两个不同字段,别混):

{
"registry-mirrors": ["http://192.168.1.10:5001"]
}

重启 Docker 后,docker pull nginx 时守护进程会先问本地缓存要。这个缓存仓库是只读拉取的(推不进去,push 会被拒),所以它和前面存自己镜像的主仓库是两个实例、两个目录,职责分开:主仓库存自己的资产,缓存仓库当公网的本地副本。缓存同样会越攒越大,治理方法还是第六节那套删除加 GC,只是缓存删错了代价小——大不了重新去 Hub 拉一次。

顺带看懂:仓库的存储结构#

理解后面删除、GC、备份为什么是那个样子,先花一分钟看懂 ./data 里的布局(路径都在 docker/registry/v2/ 之下):

  • repositories/<镜像名>/:按镜像名组织的目录,里面 _manifests/tags/<标签>/ 记录每个标签当前指向哪个 manifest,_layers/ 只是链接索引。
  • blobs/sha256/<前两位>/<完整digest>/data:所有镜像的层和配置都混存在这里,按内容哈希寻址。两个镜像共用同一个基础层时,磁盘上只有一份——这就是为什么仓库总占用远小于各镜像大小之和,也是为什么「删一个标签」释放不了多少空间:层可能还被别的标签引用着。

记住这个模型:标签和清单是「目录」,blob 才是「实体」。删除操作动的是目录,GC 干的是清掉没有目录指向的实体。后面两节就都顺理成章了。

第五步:配个 Web 界面(docker-registry-ui)#

纯 API 查仓库很不直观,社区有个轻量 UI:joxit/docker-registry-ui,能浏览仓库/标签、看每个标签的大小和层信息。在同一个 Compose 里加一个服务即可:

registry-ui:
image: joxit/docker-registry-ui:latest
container_name: registry-ui
restart: unless-stopped
ports:
- "127.0.0.1:8080:80"
environment:
- REGISTRY_TITLE=私有镜像仓库
- REGISTRY_URL=http://registry:5000
- DELETE_IMAGES=true
depends_on:
- registry

要点:

  • REGISTRY_URL 填容器网络里能直达的地址,这里用服务名 registry:5000 走内网,不绕公网。
  • DELETE_IMAGES=true 打开界面上的删除按钮。删除能真正生效还需要 Registry 侧允许删除 API,下一步在 Registry 的环境变量里加一行 REGISTRY_STORAGE_DELETE_ENABLED=true 并重建。
  • UI 本身没有独立的账号体系,它会把你的浏览器输入的账号密码转发给 Registry 做 Basic Auth——所以前提是 Registry 已开 htpasswd 认证;UI 的端口同样只绑本机或走反代加 HTTPS,别裸奔。
WARNING

界面上点「删除」删掉的只是这个标签的 manifest(清单),镜像的层数据(blob)还躺在磁盘上占着空间,不会立刻释放。这不是 UI 的 bug,而是 Registry 的存储模型——真正回收空间要靠下一节的垃圾回收。很多人删了半天发现磁盘没变小,就是卡在这里。

第六步:空间治理与垃圾回收#

私有仓库最常见的运维问题就是磁盘被旧镜像悄悄吃满:CI 每天推几个版本、没人删,几个月后 ./data 涨到几十上百 GB。治理分两步,缺一不可:

第一步:删掉不要的标签。 用 UI 删除,或者调 API:

Terminal window
# 先查这个标签对应的 digest
curl -u admin:你的密码 -I \
-H "Accept: application/vnd.docker.distribution.manifest.v2+json" \
https://registry.example.com:5000/v2/myapp/manifests/0.9
# 从返回头 Docker-Content-Digest 取值,再按 digest 删除
curl -u admin:你的密码 -X DELETE \
https://registry.example.com:5000/v2/myapp/manifests/sha256:<digest值>

前提是 Registry 开了 REGISTRY_STORAGE_DELETE_ENABLED=true,没开会返回 405。

第二步:执行垃圾回收(garbage-collect),把没有 manifest 引用的 blob 真正从磁盘清掉。正确的执行姿势:

Terminal window
cd /opt/registry
# 1. 先停仓库,避免回收期间有人推拉造成数据不一致
docker compose stop registry
# 2. 用一次性容器跑 GC(--delete-untagged 连没有标签的清单一并清掉)
docker compose run --rm registry \
bin/registry garbage-collect --delete-untagged /etc/docker/registry/config.yml
# 3. 再启动
docker compose up -d
WARNING

GC 期间仓库必须停写。官方支持不停机跑(把存储切到只读模式),但对个人部署来说,挑个低峰期停几分钟执行是最稳妥的做法,边推边回收有可能把正在上传的层误判成垃圾删掉。另外 GC 前建议先把 data 目录整体备份一份,回收错了还有退路。

回收效果用 du -sh data 前后对比即可确认。养成习惯:给 CI 的镜像标签带构建号的同时,定期清理老版本,只保留最近若干个。

第七步:仓库数据的备份#

仓库的全部家当就是 ./data 目录(镜像层和清单都在里面),外加 auth/htpasswd 和证书。备份策略和普通数据卷一致:停服或低峰期打包目录、异地再存一份,具体方法直接套用 《数据卷与备份恢复》 里的卷备份流程,这里只强调一点:仓库备份要定期做恢复演练——拉一个备份出来起一个临时 Registry,能 _catalog 列出镜像才算备份有效。

第八步:什么时候升级 Harbor#

如果你的使用已经越过前面说的选型线,Harbor 的离线部署梗概如下,心里有个谱:

  • 组件构成:Harbor 不是一个容器,而是一组:门户(Portal)、核心服务(Core)、自带的 Registry、Job Service(复制/GC 等异步任务)、数据库(PostgreSQL)、缓存(Redis)、日志收集,再加可选的 Trivy 扫描器。
  • 安装方式:从 GitHub 发布页下载离线安装包(harbor-offline-installer-v<版本>.tgz,自带全部镜像,不依赖外网拉取),解压后改 harbor.yml(填主机名/域名、HTTPS 证书路径、管理员初始密码、数据目录),然后执行 ./install.sh(需要带 Trivy 就加 --with-trivy),它会生成 Compose 文件并一键拉起整套服务。
  • 值得上的规模:团队 3 人以上共用、需要按项目分权限、有安全合规或等保要求要做漏洞扫描拦截、多环境/多机房要做镜像复制。反之,一个人一两台机器,Harbor 的运维成本(升级、备份它自己的数据库)会超过收益,老老实实 Registry。

升级到 Harbor 后,日常真正会高频用到的其实是三个东西,先有个概念:**项目(Project)**是权限和配额的边界,镜像按项目组织,成员角色分管理员/开发者/访客(只读);**机器人账号(Robot Account)**是给 CI 和服务器拉取专用的、权限可限定到单个项目、可设过期的凭据,替代真人账号到处飞;**保留策略(Retention)**按规则自动清理老标签(比如只保留最近 10 个版本),配合定时 GC,把本文第六节那套手动治理变成平台自动执行。这三样恰好对应 htpasswd 方案最疼的三个点——分不了权、凭据管不住、清理靠手动,这也是判断「该不该升级」最实在的对照表。

Harbor 的数据模型和 Registry 是两套东西,迁移时不是改个地址就行,而是把旧仓库镜像逐个 pull → tag → push 搬过去,或者用 Harbor 的仓库复制功能从旧 Registry 同步——规划升级时把这块工作量算进去。

排障#

push 报 http: server gave HTTP response to HTTPS client

最经典的一个。客户端默认用 HTTPS 访问仓库,但仓库实际在说 HTTP(没配 TLS),协议对不上。两个解法:给仓库配上正式证书(路线 A),或者在客户端的 daemon.json 里把该地址加进 insecure-registries 并重启 Docker。后者只适合内网,别拿它当长期方案。

login 或 push 报 unauthorized / denied: requested access to the resource is denied

按顺序查:①账号密码对不对(拿同一组账号 curl -u 请求 /v2/ 验证);②htpasswd 文件路径和挂载对不对,进容器 docker exec registry cat /auth/htpasswd 看内容在不在;③密码文件是不是用了非 bcrypt 的老加密方式,新版 Registry 不认,重新用 -Bbn 生成;④docker login 的地址和 push 目标地址前缀是否完全一致(带不带端口都算不同地址)。

报 x509: certificate signed by unknown authority

证书是自签的或企业 CA 签的,客户端系统不认识这个签发者。要么把 CA 根证书放进客户端的 /etc/docker/certs.d/<仓库地址>/ca.crt(这是 Docker 官方的按仓库信任方式,比全局 insecure 更克制),要么换 Let’s Encrypt 这类公共证书。自签+insecure 也能绕过,但等于放弃验证,不推荐。

经反代 push 大镜像报 413 或中途断开

走路线 A 的反代方案时常见:小镜像能推、几百 MB 的大层推到一半失败或直接 413。原因是反代默认的请求体大小和超时在拦:Nginx 需要在对应的 server/location 里放开 client_max_body_size 0;(0 表示不限)并调大 proxy_read_timeout / proxy_send_timeout,同时确认没有开会把上传攒在内存里的请求缓冲(大文件上传建议 proxy_request_buffering off;)。改完 reload 反代再重试,这类问题基本都在反代层,不在 Registry 本身——直连 5000 端口能推成功,就是铁证。

pull 时报 manifest unknown 或 blob 404

先确认标签名没写错(tags/list 查一遍)。如果标签在、拉取中途 404,多半是存储层数据不一致:比如手动删过 data 目录里的文件、GC 时机不当删掉了还在引用的层,或者磁盘满导致某层没写完。这种情况没有太好的修复手段,删掉这个标签重新 push 一份完整的;这也是为什么前文反复强调 GC 要停写、目录别手动乱删。

UI 上没有删除按钮,或删除时报 405

先确认 UI 的环境变量 DELETE_IMAGES=true 已设置且重建过容器;再确认 Registry 侧开了 REGISTRY_STORAGE_DELETE_ENABLED=true——删除 API 默认是关闭的,没开时任何客户端删除都会收到 405 Method Not Allowed,UI 只是把这个错误透传出来而已。两个开关都对了还不行,就绕过 UI 直接用上一节的 API 删除命令验证,先把问题定位在 UI 还是 Registry 本身。

磁盘满导致 push 失败

push 到一半卡住或报 no space left on device,先 df -h 和 du -sh /opt/registry/data 对一下,再走第六节的删除+GC 流程。临时救急可以先清掉确认无用的老标签再 GC,不要直接去 data 目录里删文件——删错 blob 就会出现上面那种 404。

小结#

  • 私有仓库的核心价值是:拉取快、镜像不公开、躲开公共仓库限流、可用性自主。
  • 个人和小团队用官方 Registry 就够:Compose 一个服务起步,htpasswd 认证和 HTTPS 是底线配置,不是可选装饰。
  • 删除标签只是删清单,垃圾回收停写执行才能真正把磁盘空间收回来。
  • 仓库数据就是一个目录,备份和恢复演练都要跟上。
  • 等权限分级、漏洞扫描、跨仓库复制这些需求真的出现时,再沿着 Harbor 的路线升级——工具跟着规模走,不要反过来。
私有镜像仓库:Registry 与 Harbor 部署实战
https://blog.anexns.com/posts/docker-private-registry/
作者
Anexns
发布于
2026-11-15
许可协议
CC BY-NC-SA 4.0