从零搭建 Docker 私有仓库:Registry、HTTPS 与团队实践
2026/9/14 4:50:54 网站建设 项目流程

搞 Docker 这些年,我越来越觉得私有仓库是迟早要自己搭一遍的东西。之前一直在公共镜像仓库上拉官方镜像,速度不稳定不说,团队内部构建出来的镜像想统一管理,总不能一个个 tar 包传来传去。后来在几台内网服务器上搭了 Docker 私有仓库(Docker Registry),整个镜像流转效率明显不一样了。这篇就把我用 Docker 快速部署私有仓库的完整过程写出来,从最基础的 registry 容器,到加证书、加账号认证,再到上线后常见的坑,一次讲清楚。

这篇内容适合想在内部环境搭建镜像仓库的运维、后端开发,也适合刚接触 Docker 没多久、想把镜像统一管起来的同学。你会看到一套“先用起来再逐步完善”的搭建路径,而不是一上来就抛一堆重组件。

1. 部署之前先想清楚:你到底需要什么样的私有仓库

1.1 轻量 Registry 和完整制品库怎么选

Docker 官方的私有仓库镜像叫registry,它实际上是一个很纯粹的镜像存储与分发服务,只提供一套 HTTP API(也就是常说的 Registry V2 协议),没有网页界面,也没有复杂权限体系。它的特点是轻、稳、部署快,一个容器起来就是仓库。

与之对应的是 Harbor,它在 Registry 外面包了一层完整的企业级能力:Web 管理界面、多项目隔离、RBAC 权限、漏洞扫描、镜像复制、Quota 配额等等。部署起来要依赖 PostgreSQL、Redis,组件更多,适合需要多人协作、安全审计比较严格的公司内部使用。

我的建议是,先按规模和诉求来定。个人学习、小团队内部用、边缘节点同步镜像,直接上registry:2就行。如果你在的是一个二三十人的研发团队,项目多、角色杂、要给人开不同权限,那这个时候再上 Harbor 也完全不迟。没必要一上来就把系统搞得很重,因为私有仓库的复杂度不是来自镜像本身,而是来自“谁有权限推拉、镜像放哪、怎么审计”这些管理问题。

这里我给一个对比,方便你决策:

维度Docker RegistryHarbor
部署复杂度一个容器搞定需要 Postgres、Redis 等多个组件
Web 界面有完整管理后台
权限控制依赖 htpasswd 或外部认证项目级 RBAC
镜像删除/GC调用 API + 手动触发 GC界面操作 + 自动回收机制
适合场景个人/小团队/边缘节点中大型研发团队

1.2 私有仓库到底解决了什么问题

公共镜像仓库能解决“从网上拉镜像”的问题,但解决不了内部镜像怎么统一管理的问题。我举几个实际场景你就明白了:

第一,内网下载速度快。服务器在自己机房或者云上内网,客户端从私有仓库拉镜像走的是内网带宽,几十 GB 的基础镜像秒级到分钟级拉完,比从公网一个个层拖下来体验好太多。

第二,镜像可追踪、可复用。团队里构建好的应用镜像,统一推到私有仓库后,测试、生产环境按 tag 拉取,版本清晰,不会出现“你这镜像哪来的”“这包是不是最新的”这种问题。

第三,离线环境能交付。很多内网生产环境完全隔离外网,这时候私有仓库就是一个“镜像中转站”。在有网的机器拉好镜像,推送到内网私有仓库,生产机器再从私有仓库拉,整个交付链路就通了。

私有仓库经常被比作“镜像的 GitHub”。这个比喻很贴切:代码要托管到统一的 Git 仓库,镜像也应该有统一的存储和版本管理。唯一的区别是,GitHub 是公网服务,镜像仓库你可以用 Docker 在自己服务器上 5 分钟搭一个。

1.3 整体架构长什么样

先把最小架构说清楚。你只需要一台装好 Docker 的服务器,在主机上创建三个目录:

  • 数据目录:比如/opt/registry/data,用来存放镜像层数据。
  • 证书目录:比如/opt/registry/certs,存放 HTTPS 证书和私钥。
  • 认证目录:比如/opt/registry/auth,存放账号密码文件。

然后启动一个registry:2容器,把宿主机的 5000 端口映射到容器内的 5000 端口,把上面三个目录分别挂载进去,再通过环境变量告诉 Registry 证书和认证文件的位置。

为什么把目录挂载出来?因为容器本身是脆弱的,你随时可能删掉重建。如果镜像数据直接写在容器可写层里,容器一删,仓库里的所有镜像也跟着没了。把数据放在宿主机目录,容器怎么重建都没关系。

为什么端口用 5000?这是 Docker Registry 的默认端口,官方文档、各种客户端工具都默认认这个端口,不用额外记忆。

生产环境我强烈建议加 HTTPS。不加 HTTPS 的话,所有流量都是明文,镜像内容在网络上裸奔,这在内网还好说,一旦跨网络传输就是安全隐患。而且加了 HTTPS 之后,客户端配置反而更简单:不用给 Docker daemon 设insecure-registries,只要信任你的自签名证书就行。

2. 最简可用版:5 分钟把 Registry 跑起来

2.1 拉镜像、起容器、先验证端口通不通

先在宿主机上拉取官方镜像:

docker pull registry:2

然后直接启动一个最简容器。这一步先不配证书和认证,只为了让环境先通起来:

docker run -d \ --name registry \ -p 5000:5000 \ -v /opt/registry/data:/var/lib/registry \ --restart=always \ registry:2

启动之后用下面的命令验证:

curl http://localhost:5000/v2/_catalog

正常情况下会返回一个空 JSON,类似{},表示服务正常。如果你在公网上或者跨机器访问,记得先确认宿主机防火墙放行 5000 端口,云服务器还要在安全组里加规则。这一步经常被忽略,导致本机 curl 正常,别的机器就是连不上。

如果你的 Docker 权限有问题,比如执行 docker 命令时报:

Got permission denied while trying to connect to the Docker daemon socket

那是因为当前用户不在 docker 用户组里。执行下面的命令把用户加进去:

sudo usermod -aG docker $USER

然后退出终端重新登录,再执行 docker 命令就正常了。这个问题在刚接触 Docker 的机器上非常常见,顺手记一下。

2.2 数据卷、证书目录和配置项说明

最简版跑通了,但还差两步才敢长期用:一是把证书目录和认证目录挂进去,二是把关键配置项通过环境变量注入容器。

Registry 容器内的配置文件路径是/etc/docker/registry/config.yml。你不需要去改这个文件,Registry 支持用环境变量覆盖配置,命名规则是把配置文件里的层级结构用下划线连起来。比如配置里的:

http: tls: certificate: /certs/domain.crt key: /certs/domain.key

对应的环境变量是REGISTRY_HTTP_TLS_CERTIFICATEREGISTRY_HTTP_TLS_KEY

这种“配置文件分段 + 环境变量覆盖”的设计很常见,好处是容器的镜像本身不用改,所有个性化配置都在启动命令里。这也是为什么我一直推荐用docker rundocker compose来管理 Registry,而不是把配置写死在镜像里。

挂载目录时有个细节:容器内 Registry 的数据目录是/var/lib/registry,这个路径千万别记错。有些同学挂载成/var/lib/docker或者随便挂一个目录,结果启动没报错,但是容器重建之后镜像全没了,就是这个路径搞错了。

容器启动参数我建议固定加--restart=always。Registry 作为基础服务,机器的 Docker 服务重启了、或者宿主机重启了,容器要能自动拉起来。不加这个参数,哪天机器重启,你的仓库服务就一直挂着,客户端那边只会收到连接失败的报错。

2.3 本地推镜像:走上 HTTPS 或明文信任

现在仓库服务已经起来了,但你直接用docker push推镜像大概率会失败。原因很简单:Registry 只提供 HTTP 明文服务,而 Docker 客户端默认用 HTTPS 访问仓库地址。两者不匹配,Docker 会直接拒绝连接。

解决办法有两个:给 Registry 配上 HTTPS 证书,或者在客户端 Docker daemon 里把仓库地址加入insecure-registries。二选一,但我的建议很明确:自己生成一套自签名证书,配 HTTPS。原因后面会细说。

先生成证书。我在服务器上通常会创建一个certs目录,然后用 openssl 生成自签名证书,命令如下:

mkdir -p /opt/registry/certs cd /opt/registry/certs openssl req -newkey rsa:2048 -nodes \ -keyout domain.key \ -x509 -days 365 \ -out domain.crt \ -subj "/CN=192.168.1.100" \ -addext "subjectAltName=DNS:localhost,IP:127.0.0.1,IP:192.168.1.100"

其中的192.168.1.100换成你实际的服务器 IP。如果你有域名,也可以用域名来访问,那-subjsubjectAltName里的值改成域名就行。自签名证书的有效期默认我写的一年,实际生产环境你可以根据自己的证书管理制度调整天数。

证书生成之后,重新创建 Registry 容器,把证书挂载进去并用环境变量指定:

docker run -d \ --name registry \ -p 5000:5000 \ -v /opt/registry/data:/var/lib/registry \ -v /opt/registry/certs:/certs \ -e REGISTRY_HTTP_TLS_CERTIFICATE=/certs/domain.crt \ -e REGISTRY_HTTP_TLS_KEY=/certs/domain.key \ --restart=always \ registry:2

接下来的问题就是:客户端怎么信任这张自签名证书。Linux 上的 Docker 守护进程(dockerd)会读取/etc/docker/certs.d/<仓库地址>/下的证书。以192.168.1.100:5000这个仓库地址为例,需要这样做:

mkdir -p /etc/docker/certs.d/192.168.1.100:5000 cp domain.crt /etc/docker/certs.d/192.168.1.100:5000/ca.crt

复制完证书后重启 Docker 服务:

sudo systemctl restart docker

Windows 和 macOS 上如果你用的是 Docker Desktop,则要把domain.crt导入到系统的受信任根证书颁发机构里,然后重启 Docker Desktop。这个过程在 Windows 上相对繁琐,我实际测试时发现 Linux 客户端的信任方式最直接。

如果你就是想快速验证,可以走insecure-registries的明方式。Linux 下编辑/etc/docker/daemon.json

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

然后重启 Docker 服务。这种方式确实快,但推送和拉取都是明文流量,而且 Docker Desktop 下改这个配置还要重启 Docker Desktop 才能生效,所以除非是纯内网临时测试,否则我建议你用 HTTPS。

配置完之后,把一个本地镜像打个 tag 再推送:

docker tag nginx:alpine 192.168.1.100:5000/nginx:alpine docker push 192.168.1.100:5000/nginx:alpine

这里有一个新手最容易踩的坑:镜像 tag 必须带上仓库地址前缀。如果你只执行docker push nginx:alpine,Docker 会默认把它推送到 Docker Hub 上去,而不是你的私有仓库。这个误操作轻则浪费流量,重则把你的镜像内容传到公网,非常危险。所以“先 tag 再 push”这个习惯一定要养成。

3. 从能跑到能用:认证、查询与统一入口

3.1 账号密码认证与 Bearer Token 流程

很多人私有仓库起来之后发现一个问题:任何能访问到这个仓库地址的人都能随意推拉镜像。内网环境里这看起来问题不大,但一旦扩大使用范围,这几乎等于把服务器裸奔。所以第二个必须做的事就是加认证。

Registry 的认证方式很简单,用 htpasswd 文件存储账号密码。先生成密码文件:

mkdir -p /opt/registry/auth docker run --rm --entrypoint htpasswd httpd:2 -Bbn admin YourPassword > /opt/registry/auth/htpasswd

这里我用的httpd:2镜像自带的 htpasswd 工具,你也可以直接用系统安装的htpasswd命令。-B表示使用 bcrypt 加密,-b表示直接在命令行传入密码,-n表示输出到标准输出而不是写文件。一定要用-B,因为 Registry 对 bcrypt 格式支持最稳定,有些旧版的 MD5 格式会登录失败。

然后重新创建容器,加认证相关的环境变量:

docker run -d \ --name registry \ -p 5000:5000 \ -v /opt/registry/data:/var/lib/registry \ -v /opt/registry/certs:/certs \ -v /opt/registry/auth:/auth \ -e REGISTRY_AUTH=htpasswd \ -e REGISTRY_AUTH_HTPASSWD_REALM="Registry Realm" \ -e REGISTRY_AUTH_HTPASSWD_PATH=/auth/htpasswd \ -e REGISTRY_HTTP_TLS_CERTIFICATE=/certs/domain.crt \ -e REGISTRY_HTTP_TLS_KEY=/certs/domain.key \ --restart=always \ registry:2

之后客户端第一次访问私有仓库时,需要先登录:

docker login 192.168.1.100:5000

输入用户名密码,Docker 会把这个仓库地址的认证信息保存在本地,后续 push/pull 就会自动携带凭证。

聊一下这个认证流程,它能帮你理解很多奇奇怪怪的报错。当 Docker 客户端向 Registry 发起一个请求时,如果 Registry 开启了认证,它会先返回 401,并带一个WWW-Authenticate头,指向认证服务地址。客户端收到 401 后用本地保存的账号密码去换一个 Bearer Token,再用这个 Token 去访问实际资源。所以你会发现,如果你不在docker login里输入账号,直接 push 就会报 401 Unauthorized;登录过一次之后,后续操作都很顺畅,就是这个 Token 在起作用。

3.2 没有页面怎么管理镜像:API 就是你的界面

Registry 默认没有管理页面,很多人一开始会不习惯。但实际上它暴露的 HTTP API 足够完成绝大多数管理操作,配合 curl 和 jq 就能快速查询、删镜像。

先看仓库里有哪些镜像:

curl -u admin:YourPassword https://192.168.1.100:5000/v2/_catalog

返回结果是一个 JSON,里面是仓库名称列表。再查某个镜像有哪些 tag:

curl -u admin:YourPassword https://192.168.1.100:5000/v2/nginx/tags/list

如果你在脚本里用,建议装一下 jq:

alias reg='curl -s -u admin:YourPassword https://192.168.1.100:5000' reg/v2/_catalog | jq reg/v2/nginx/tags/list | jq

删除镜像则是先拿到 manifest 的 digest,再调用 DELETE 接口。以删除nginx:latest为例:

# 获取 digest curl -I -u admin:YourPassword -H "Accept: application/vnd.docker.distribution.manifest.v2+json" https://192.168.1.100:5000/v2/nginx/manifests/latest # 拿到 Docker-Content-Digest 的值,然后删除 curl -u admin:YourPassword -X DELETE https://192.168.1.100:5000/v2/nginx/manifests/<digest>

注意,这里的Accept头必须要带,否则 Registry 返回的 digest 可能不是 manifest 的 digest,删的时候会 404。

关键点:Registry 的 DELETE 接口默认是关闭的。不开启时你执行 DELETE 会收到 405 或者 404。要开启删除能力,需要在启动容器时加一个环境变量:

-e REGISTRY_STORAGE_DELETE_ENABLED=true

3.3 想让团队用起来更舒服?加统一入口

Registry 容器监听的是 5000 端口,地址看起来很技术。如果团队内部想把仓库统一放在一个规范域名下,比如registry.example.com,并且用标准的 443 端口,可以通过 nginx 做一个前置转发层,把外部请求转发到 Registry 容器。

这个其实很好理解:你可以在一台机器上把 443 端口的流量转给 5000 端口,对外暴露的地址就变成了https://registry.example.com,而不是https://192.168.1.100:5000。最终客户端的访问体验和用 Docker Hub 几乎一样,不需要记端口号。

nginx 的核心配置大致是这样:

server { listen 443 ssl; server_name registry.example.com; ssl_certificate /etc/nginx/conf.d/domain.crt; ssl_certificate_key /etc/nginx/conf.d/domain.key; client_max_body_size 0; location / { proxy_pass http://127.0.0.1:5000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } }

有两个细节需要注意。第一,client_max_body_size必须设成 0 或者足够大,否则大镜像上传时会被 nginx 拦截,报 413 Request Entity Too Large。第二,如果 nginx 这边已经做了 TLS 终止,Registry 容器自己就不用再挂证书了,否则会出现双重加密。反过来,如果你让 Registry 自己处理 HTTPS,nginx 那边就用 http 转发到 5000。两种方式都能跑通,只要别混着来。

如果只是团队内部用,没有统一域名需求,那直接把 IP:5000 发给同事也行。这个前置入口不是必须的,但它能让仓库地址更规范、更容易记忆。

4. 上线之后最常踩的坑:问题排查与避坑清单

4.1 高频报错速查表

把常见错误整理成一张速查表,你遇到问题直接对照着看:

报错信息可能原因处理方式
x509: certificate signed by unknown authority客户端不信任自签名证书把 CA 证书放到/etc/docker/certs.d/<仓库地址>/
http: server gave HTTP response to HTTPS client仓库是 HTTP,客户端默认走 HTTPS配置 insecure-registries 或改用 HTTPS
Get ... connection refused容器没启动或防火墙挡了 5000 端口检查容器状态,放行防火墙和安全组规则
401 Unauthorized未登录或认证配置没生效执行 docker login,检查 htpasswd 路径
denied: requested access to the resource is denied镜像 tag 未加仓库前缀或没有权限补全 tag 地址,检查仓库名称
413 Request Entity Too Largenginx 转发层限制了 body 大小设置 client_max_body_size 0
磁盘空间不足镜像层堆积,删除接口未启用或没做 GC开启 DELETE,执行垃圾回收

表格里的前两个错误是最常见的,每天都在不同机器上反复出现。两者其实是一个问题的两面:仓库端用了 HTTPS 你就得让客户端信任证书,仓库端用了 HTTP 你就得让客户端放弃 HTTPS。搞清楚自己仓库到底是哪种模式,排查就快多了。

4.2 那些不报错但是会让你崩溃的细节

比报错更难受的是“看起来一切正常,但实际没做对”。

第一个坑:tag 忘记加仓库前缀。前面说过了,但值得再强调一次。docker push nginx:alpinedocker push 192.168.1.100:5000/nginx:alpine的目标天差地别。前者在没有任何配置的情况下会尝试推到 Docker Hub,后者才推到你自己的仓库。很多人在这一步把内部镜像泄露到公网,一定要警惕。

第二个坑:删了镜像,磁盘空间没释放。Registry 的 DELETE API 只是把镜像的元数据标记为删除,底层存储在/var/lib/registry里的 blob 文件不会立即消失。必须手动执行垃圾回收。命令是:

docker exec registry bin/registry garbage-collect /etc/docker/registry/config.yml

garbage-collect是 Registry 自带的子命令,它会扫描当前存储目录,把所有没有被任何 manifest 引用的 blob 删掉。这是真正的空间释放步骤,不做的话,磁盘只会越用越多。

第三个坑:跨机器推不上去,先查防火墙,再查证书。很多人在本机 push 成功,换一台机器就失败,考虑的大部分问题都在证书信任上,其实更常见的原因是防火墙没放行 5000 端口。排查顺序我一般是这样:

# 在客户端机器上先测端口通不通 telnet 192.168.1.100 5000 # 通了之后再试登录 docker login 192.168.1.100:5000

如果端口都不通,后面所有证书、认证的排查都是在浪费时间。先解决连通性,再看协议和证书,这是最基本的排查思路。

4.3 磁盘清理和镜像归档的完整脚本

生产环境跑了一段时间后,你会看到/var/lib/registry的体积越来越大。我之前维护过一个仓库,数据目录居然涨到了上百 GB,其中一个原因就是开发同事每天都在推送新的latesttag,旧镜像的 blob 一直留在磁盘上。

我自己常用的清理思路是:先通过 API 把每个仓库的旧 digest 删掉,再执行一次 GC。下面这个脚本用一个稍旧的 tag 名清理示例,实际使用时建议结合你自己的 tag 保留策略:

#!/bin/bash REGISTRY=192.168.1.100:5000 AUTH="admin:YourPassword" # 所有仓库列表 repos=$(curl -s -u "$AUTH" "https://$REGISTRY/v2/_catalog" | jq -r '.repositories[]') if [ -z "$repos" ]; then echo "No repositories found." exit 0 fi for repo in $repos; do echo "cleanup repo: $repo" # 获取 tags tags=$(curl -s -u "$AUTH" "https://$REGISTRY/v2/$repo/tags/list" | jq -r '.tags[]' 2>/dev/null) if [ -z "$tags" ]; then continue fi # 这里示例只保留 latest,其它 tag 全删 for tag in $tags; do if [ "$tag" = "latest" ]; then continue fi digest=$(curl -s -I -u "$AUTH" -H "Accept: application/vnd.docker.distribution.manifest.v2+json" \ "https://$REGISTRY/v2/$repo/manifests/$tag" \ | grep -i "^Docker-Content-Digest:" | awk '{print $2}' | tr -d '\r') if [ -n "$digest" ]; then curl -s -u "$AUTH" -X DELETE "https://$REGISTRY/v2/$repo/manifests/$digest" > /dev/null echo " deleted tag: $tag" fi done done echo "start garbage collection" docker exec registry bin/registry garbage-collect /etc/docker/registry/config.yml -m echo "done"

脚本里的-m参数用来在 GC 时打印内存回收信息,方便看效果。这个脚本只是一个清理雏形,实际生产环境你要根据自己的 tag 策略改保留逻辑,千万别真的把所有非 latest 的 tag 全删掉。

另外提醒一下,生产环境做 GC 要慎重。GC 过程中 Registry 服务会短暂不可用或变慢,因为它需要扫描整个存储目录,而且这个操作会加锁。不要在业务高峰时段跑,能放到凌晨就放到凌晨。

5. 从私有仓库到团队基础能力

5.1 离线环境搬镜像:save/load 组合拳

私有仓库最常见的价值体现就是离线环境。我遇到过很多次这样的场景:服务器完全访问不了外网,但应用必须跑起来,依赖的 Docker 镜像也在公网仓库里。

这时候私有仓库就有了用武之地。首先在一台有外网的机器上把需要的镜像拉下来:

docker pull nginx:alpine

然后打包成 tar 文件:

docker save nginx:alpine -o nginx-alpine.tar

把 tar 文件通过任何你能用的方式(U 盘、内置文件服务器、刻盘)传到目标机器上,然后加载:

docker load -i nginx-alpine.tar

加载完成后,给镜像打上私有仓库地址前缀,推送到内网仓库:

docker tag nginx:alpine 192.168.1.100:5000/nginx:alpine docker push 192.168.1.100:5000/nginx:alpine

这样,内网其他机器就能直接从私有仓库拉取这个镜像,再也不用一台台机器去 load tar 包。说实话,我第一次在内网服务器上处理几十个镜像时,这个方法帮我节省的时间按小时算都不夸张。

5.2 和 CI/CD 配合:登录凭证不要写进代码

私有仓库稳定运行之后,第二个自然而然的用途就是接 CI/CD。不管是 GitLab CI、Jenkins 还是 GitHub Actions,构建完镜像之后推送到内网私有仓库,部署阶段再从仓库拉取,整个流程才算闭环。

这里我想特别强调一个安全问题:docker login的账号密码不要明文写在 CI 配置文件里,更不要提交到代码仓库。很多团队搭建私有仓库后,第一版 CI 配置里直接写:

docker login 192.168.1.100:5000 -u admin -p YourPassword

这样做一旦代码仓库权限泄露,仓库账号密码也跟着泄露。合理的做法是使用 CI 平台提供的 Secret / 凭据管理功能,把账号密码配成受保护的变量,流水线运行时由平台注入。这是一个非常容易被忽略但极其重要的细节。

另外,CI 环境中每次构建前做好docker logout也是一个好习惯,避免凭证被缓存在构建机上,给后续使用同一台构建机的任务留下安全隐患。

我个人的习惯是:私有仓库这类基础组件,前期多花半小时把证书、认证、清理策略全部配好,后期能省无数个不眠之夜。明文跑通只是第一步,但绝不是终点。如果你正打算在自己团队内搭私有仓库,我建议就从我上面这套方案开始:先用registry:2快速跑起来,再逐步加上 HTTPS、认证、GC 脚本、CI 集成。等你把这些都理顺了,你会明显感觉到镜像流转这件事变得非常顺畅,大家再也不会为了一个镜像文件互相传包了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询