一、先给镜像找个角落
服务器用久了,总会攒下一堆自己构建的镜像。有的来自深夜调试,有的来自一次次迭代,还有一些,是只属于自己的项目。它们散落在各台机器里,时间一长,自己也说不清哪个版本才是对的。
于是我想,不如给它们安一个固定的家。
我不需要复杂的对象存储,也不想把镜像放到公共仓库里供人随意拉取。我要的很简单:一个安静的小仓库,带着一把锁,只有我和我授权的账号才能进出。
先选一个目录。我习惯把所有自部署的程序放在 ~/program/ 下面,这次也不例外。
cd ~/program/
mkdir docker-registry
cd docker-registry/
# 配置目录:放主配置文件和账号密码文件
mkdir config
# 镜像数据目录:这是最重要的数据,将来备份它就行
mkdir data
目录很简单,甚至有点朴素。但一座仓库,本来也不在于门面,而在于里面的东西是否安稳。
二、给仓库上第一把锁
仓库建好了,不能门户大开。
Docker Registry 本身支持基于 htpasswd 的 Basic Auth。虽然不算顶级安全,但对个人使用来说,已经像一把足够结实的铜锁。
生成账号密码文件,我用了官方 httpd 镜像里的 htpasswd 工具。第一次创建管理员账号 admin:
sudo docker run --rm --entrypoint htpasswd docker.1ms.run/httpd:2 -Bbn admin '你的密码' | sudo tee config/htpasswd >/dev/null
这里注意,一定要把 '你的密码' 替换成自己的强密码。-Bbn 表示使用 bcrypt 加密,并且在终端输出结果。输出通过管道写进 config/htpasswd 文件里,不会在屏幕上显示出来。
后来如果需要给 CI 单独开一个账号,不用重建文件,追加即可:
sudo docker run --rm --entrypoint htpasswd docker.1ms.run/httpd:2 -Bbn ci '换成CI密码' | sudo tee -a config/htpasswd >/dev/null
-a 参数会告诉 tee 追加,而不是覆盖。这样 admin 和 ci 两个账号就都住在同一个密码文件里了。
三、告诉 Registry 它该怎么做:config.yml
有了锁,接下来要告诉 Registry 一些规矩。比如镜像存到哪里、允不允许删除、日志等级、安全头,以及认证方式。
我在 config 目录下新建了 config.yml。它就像仓库的管理章程。
version: 0.1
log:
level: info
storage:
filesystem:
# 镜像实际存储目录,对应 docker-compose.yml 里挂载到 /var/lib/registry 的本地目录
rootdirectory: /var/lib/registry
delete:
# 允许 UI 删除镜像 manifest,不开启这个 UI 删除按钮会失败
enabled: true
cache:
# 小规模个人使用用内存缓存即可,不需要 Redis
blobdescriptor: inmemory
http:
# registry 容器内部监听端口,不直接暴露到公网
addr: :5000
headers:
# 官方推荐的安全头
X-Content-Type-Options: [nosniff]
# UI 删除镜像时需要拿到 Docker-Content-Digest
# 如果 UI 和 registry 同域,CORS 压力会小很多
Access-Control-Allow-Origin: ['https://registry.dpangzi.com']
Access-Control-Allow-Methods: ['HEAD', 'GET', 'OPTIONS', 'DELETE']
Access-Control-Allow-Headers: ['Authorization', 'Accept', 'Cache-Control']
Access-Control-Allow-Credentials: [true]
Access-Control-Expose-Headers: ['Docker-Content-Digest']
auth:
htpasswd:
# Docker login 时看到的认证域名称
realm: Registry Realm
# htpasswd 文件在容器内的位置
path: /auth/htpasswd
几个地方值得多说几句。
delete.enabled: true 这一项,如果不开启,Web UI 上的删除按钮会失败。对于个人仓库来说,删除旧 tag、清理无用 manifest 是常有的事,所以这个开关很重要。
Access-Control-Allow-Origin 我填的是自己的域名 https://registry.dpangzi.com。如果你也准备用域名访问 UI,记得替换成自己的。否则浏览器会出现跨域问题,UI 界面上操作删除或查看 digest 时容易莫名其妙地失败。
auth.htpasswd 部分指向容器内的 /auth/htpasswd,这个文件在 docker-compose.yml 里会被挂载进去。也就是说,我们刚刚生成的那把锁,会挂到仓库大门上。
四、用 Compose 把 Registry 和 UI 拉起来
单有 Registry 还不够。虽然命令行可以完成所有操作,但偶尔还是想打开网页,看看仓库里躺着哪些镜像、每个镜像有哪些 tag、某个 tag 的 digest 是什么。
所以我搭配了一个轻量的 docker-registry-ui。它和 Registry 放在同一个 docker-compose.yml 里,彼此通过内部网络通信。
name: private-registry
services:
registry:
# CNCF Distribution 官方 Registry 镜像
image: docker.1ms.run/registry:3.1.1
container_name: registry-server
restart: unless-stopped
environment:
# 关闭默认 OpenTelemetry 导出,避免日志里出现无关的 OTEL 连接提示
OTEL_TRACES_EXPORTER: none
REGISTRY_AUTH: htpasswd
REGISTRY_AUTH_HTPASSWD_REALM: Registry Realm
REGISTRY_AUTH_HTPASSWD_PATH: /auth/htpasswd
volumes:
# registry 主配置文件
- ./config/config.yml:/etc/docker/registry/config.yml:ro
# Basic Auth 用户密码文件,由 htpasswd 生成
- ./config/htpasswd:/auth/htpasswd:ro
# 镜像数据目录,这是最重要的数据,需要备份
- ./data:/var/lib/registry
networks:
- registry-net
# 不写 ports,表示 registry 不直接暴露到宿主机公网
# 所有外部访问都通过 registry-ui 和 Caddy 进入
registry-ui:
# joxit 的轻量 Registry UI
# 生产建议固定大版本 2,不建议用 main,main 是 beta 分支
image: docker.1ms.run/joxit/docker-registry-ui:2
container_name: registry-ui
restart: unless-stopped
depends_on:
- registry
environment:
# 单 registry 模式,页面不会让你手工添加多个 registry 地址
SINGLE_REGISTRY: "true"
# 页面标题
REGISTRY_TITLE: "Private Docker Registry - 阿胖"
# UI 内置 Nginx 把 /v2/ 请求代理到 registry 容器
# 这样 UI 和 Registry 是同一个公网域名,可以避开大部分 CORS 问题
NGINX_PROXY_PASS_URL: "http://registry:5000"
# Docker 内置 DNS,registry 容器重建后 UI 仍能解析到新 IP
NGINX_RESOLVER: "127.0.0.11"
# 告诉 UI registry 使用 Basic Auth,减少一些探测请求
REGISTRY_SECURED: "true"
# 允许在 UI 删除 tag/manifest,config/config.yml 也必须开启 delete.enabled
DELETE_IMAGES: "true"
# 在 tag 列表显示 digest,删除和排查镜像时很有用
SHOW_CONTENT_DIGEST: "true"
# UI 里复制 docker pull 命令时使用的域名,不要带 https://
PULL_URL: "registry.dpangzi.com"
# 每页 tag 数量,个人项目 100 足够
TAGLIST_PAGE_SIZE: "100"
# catalog 最大展示数量,镜像不多保持默认 1000 即可
CATALOG_ELEMENTS_LIMIT: "1000"
# 主题,auto 会跟随浏览器
THEME: "auto"
ports:
# 只监听本机 3383,不直接暴露到公网
# Caddy 会从本机反向代理到这里
- "127.0.0.1:3383:80"
networks:
- registry-net
networks:
registry-net:
driver: bridge
这里有几个设计,是我反复调整后留下来的。
第一,registry 服务没有写 ports。也就是说,Registry 本身不直接对宿主机以外的网络开放。所有对 /v2/ 的请求,都经过 UI 内置的 Nginx 转发,再进入 Registry 容器。这样外部只能通过 UI 这一条路进来,公网暴露面更小。
第二,UI 服务只监听了 127.0.0.1:3383。这也是出于安全考虑。UI 不会直接暴露在公网,而是由 Caddy 在宿主机上反向代理到 127.0.0.1:3383。将来如果要换域名、换证书,只需改 Caddy 配置,不用动容器。
第三,NGINX_RESOLVER 设置成了 Docker 内置 DNS 127.0.0.11。这一个小细节很实用:当 registry 容器重启后 IP 变化,UI 仍然能通过 DNS 解析到新的地址,不会因为写死 IP 而失联。
第四,PULL_URL 设置成了 registry.dpangzi.com,但你在 UI 里看到复制出来的 docker pull 命令,会用这个域名。如果还没有配置公网域名,可以先把它留空,或者替换成你最终要使用的域名。
五、启动、查看
配置写完了,就可以把整座仓库拉起来。
sudo docker compose up -d
sudo docker compose ps
看到两个服务都 running 的时候,心里会有一种很踏实的满足感。就像你亲手把货架组装好,又把门锁拧紧,现在它终于可以接纳第一件货物了。
如果你配置了 Caddy,可以把 registry.dpangzi.com 指向本机的 127.0.0.1:3383,配好 HTTPS 证书。这样在浏览器里打开 https://registry.dpangzi.com,就能看到 UI 登录界面。
如果没有域名也没关系,直接访问服务器本机 http://127.0.0.1:3383 也可以管理镜像。只是跨机器访问时,需要借助 SSH 隧道或者后续再配反代。
六、推送第一个镜像
仓库空着总不是办法。我拿一个实际项目 dpz.webapi 来试试。
先登录仓库。如果是在部署机上直接操作,可以用 127.0.0.1:3383:
sudo docker login 127.0.0.1:3383
输入 admin 和之前设置的密码。登录成功后,Docker 会把认证信息保存在本地,之后推送和拉取就不用反复输入了。
如果你已经配置好公网域名和 Caddy,也可以登录域名:
sudo docker login registry.dpangzi.com
两种方式本质一样,只是一个走本机回环,一个走公网 HTTPS。日常调试时我用前者多一些,因为不经过公网,速度更快,也不受证书、DNS 影响。
然后开始构建镜像。假设项目在 ~/project/dpz.core/src,Dockerfile 在 Dpz.Core.WebApi/Dockerfile:
cd ~/project/dpz.core/src
sudo docker build -t dpz.webapi -f Dpz.Core.WebApi/Dockerfile .
构建完成后,打上带仓库地址的 tag。这里 <version> 替换成具体版本号,比如 1.0.0:
sudo docker tag dpz.webapi:latest 127.0.0.1:3383/dpz.webapi:<version>
sudo docker tag dpz.webapi:latest 127.0.0.1:3383/dpz.webapi:latest
也可以构建时直接打上多个 tag,少一步操作:
sudo docker build \
-t 127.0.0.1:3383/dpz.webapi:<version> \
-t 127.0.0.1:3383/dpz.webapi:latest \
-f Dpz.Core.WebApi/Dockerfile .
两种方式都可以。我习惯后一种,一步到位,省得构建完再改 tag。
最后推送到仓库:
sudo docker push 127.0.0.1:3383/dpz.webapi:<version>
sudo docker push 127.0.0.1:3383/dpz.webapi:latest
推送过程中,进度条一层一层地跳跃,像把一件件行李搬进仓库。等命令结束,回到 UI 里刷新一下,就能看到 dpz.webapi 静静地躺在镜像列表里,点进去,latest 和 <version> 两个 tag 都好好地列在那里。
那一刻会忽然觉得,自己写过的代码、构建过的镜像,终于有了一个可以回望的归处。
七、写在最后
这套私有 Registry 已经稳定运行了一段时间。它不复杂,但足够可靠。
有几点是我特别想再提醒自己的。
第一,data 目录是整个仓库的命脉,所有镜像数据都存在那里。配置文件丢了可以重新写,账号密码丢了可以重新生成,但 data 目录一旦丢失,镜像就真的没了。所以一定要定期备份。
第二,htpasswd 是 Basic Auth,密码通过 HTTP 头传输。虽然我在 Caddy 层面套了 HTTPS,但如果你的仓库暴露在公网,务必确保不要用纯 HTTP 跑。否则密码就像写在明信片上,谁都能看见。
第三,不要追求“功能多”。对于个人和小团队来说,这样一个简单、可控、带锁的仓库,往往比各种企业级方案更顺手。它不需要数据库,不需要对象存储,不需要复杂的权限模型,却能解决最核心的问题:我的镜像,由我掌握。
夜深人静的时候,偶尔登录 UI,看着那些熟悉的镜像名,会想起每个名字背后的项目、那些调试的夜晚、那些最终跑起来的瞬间。
它们不再飘在云端,不再散落在各台服务器里,而是整整齐齐地,住在自己亲手建起的小仓库里。
门上有锁,钥匙在自己手里。
这样很好。