建一座带锁的镜像仓库:我的私有 Docker Registry 搭建记

从空目录开始,一步步搭建一个需要账号密码才能拉取、推送的私有 Docker Registry,并配上一块简洁的 Web UI。适合个人开发者、小团队,以及所有想把镜像牢牢攥在自己手里的人。

一、先给镜像找个角落

服务器用久了,总会攒下一堆自己构建的镜像。有的来自深夜调试,有的来自一次次迭代,还有一些,是只属于自己的项目。它们散落在各台机器里,时间一长,自己也说不清哪个版本才是对的。

于是我想,不如给它们安一个固定的家。

我不需要复杂的对象存储,也不想把镜像放到公共仓库里供人随意拉取。我要的很简单:一个安静的小仓库,带着一把锁,只有我和我授权的账号才能进出。

先选一个目录。我习惯把所有自部署的程序放在 ~/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 追加,而不是覆盖。这样 adminci 两个账号就都住在同一个密码文件里了。

三、告诉 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,看着那些熟悉的镜像名,会想起每个名字背后的项目、那些调试的夜晚、那些最终跑起来的瞬间。

它们不再飘在云端,不再散落在各台服务器里,而是整整齐齐地,住在自己亲手建起的小仓库里。

门上有锁,钥匙在自己手里。

这样很好。

评论加载中...