1. 私有镜像库的搭建动机与整体设计
1.1 为什么需要自建镜像库
做过容器化部署的人都有一个共同体会:当团队规模超过三五个人、项目超过十个之后,公共镜像仓库的局限性就会逐渐暴露出来。拉取速度受网络波动影响、私有镜像无处存放、镜像版本管理混乱、CI/CD流水线频繁超时——这些问题几乎每个中型以上团队都会遇到。
自建私有镜像库最直接的价值有三个层面。第一是速度,内网传输千兆起步,一个500MB的镜像从推送完成到其他节点拉取完毕,通常只需要十几秒,而走公网往往要几分钟甚至超时重试。第二是安全边界,业务镜像里往往包含内部配置、私有依赖包、甚至一些不便公开的构建产物,放在公共仓库里始终让人不踏实。第三是可控性,镜像的保留策略、清理规则、访问权限完全由自己掌握,不用担心公共仓库的限流或策略变更。
我所在的团队最初也是用公共仓库凑合,直到有一次CI流水线因为拉取超时连续失败了七次,才下决心在内网搭一套自己的镜像库。选型上几乎没有犹豫——Docker官方提供的Registry镜像本身就是最轻量的方案,一个容器就能跑起来,不需要额外引入复杂的平台组件。
1.2 整体架构与方案选型
这套方案的核心思路非常朴素:用官方registry镜像启动一个容器,把镜像数据持久化到宿主机目录,再通过Nginx做一层反向代理来处理域名、端口和TLS。为什么不直接暴露registry容器的端口?因为实际使用中你会很快遇到两个问题:一是需要HTTPS才能让Docker客户端信任,二是需要统一的入口来管理多个仓库或做访问控制。Nginx这层代理几乎是标配。
整体数据流是这样的:开发者在本地构建镜像后,通过docker push推送到私有仓库地址,请求先到Nginx,Nginx转发给registry容器,registry把镜像层数据写入挂载的宿主机目录。其他节点docker pull时走同样的路径反向读取。整个过程对使用者来说和用公共仓库没有区别,只是地址换成了内网域名。
方案里几个关键决策点值得说明。存储驱动选择filesystem而不是其他后端,因为对于中小规模团队来说,本地磁盘加定期备份已经完全够用,引入对象存储反而增加运维复杂度。认证方式采用htpasswd基础认证,简单可靠,配合Nginx的auth_basic就能实现。TLS证书用自签名证书即可,内网环境不需要公网CA签发,把根证书分发到各节点信任列表就行。
注意:自签名证书的CN字段必须和实际访问的域名一致,否则Docker客户端会报证书不匹配的错误。这一点后面排查章节会详细展开。
1.3 适用场景与前置条件
这套方案适合什么样的场景?我的判断标准是:团队规模在5到200人之间,镜像数量在几十到几千个之间,有内网环境且节点之间网络互通。如果团队只有两三个人,用公共仓库的私有空间可能更省事;如果规模到了几百人以上、镜像数量上万,那可能需要考虑带Web界面和复杂权限体系的方案。
前置条件方面,需要一台内网服务器,配置不用太高——2核4G、100G磁盘起步就能跑得很稳。操作系统建议用主流的Linux发行版,内核版本不要太老。服务器上需要提前装好Docker引擎和Docker Compose,Nginx可以用容器跑也可以用宿主机安装,我倾向于用容器,方便版本管理和迁移。
域名方面,内网DNS里加一条A记录指向这台服务器即可,比如registry.internal.example这样的地址。如果没有内网DNS,也可以在每台节点的hosts文件里写死,但节点多了之后维护成本会上升,还是建议走DNS。
2. 核心组件拆解与配置要点
2.1 Registry容器的关键参数
官方registry镜像的配置全部通过环境变量和挂载文件来完成。最核心的几个参数我逐个说明。
REGISTRY_STORAGE_FILESYSTEM_ROOTDIRECTORY指定容器内数据存储路径,默认是/var/lib/registry,一般不需要改,但挂载的时候要把宿主机的目录映射到这个路径。REGISTRY_STORAGE_DELETE_ENABLED必须设为true,否则后面想清理旧镜像时会发现删除接口被禁用,这个坑我踩过——等到磁盘快满了才发现删不掉,只能手动进目录删文件,非常狼狈。
REGISTRY_HTTP_ADDR指定监听地址,容器内默认是:5000。REGISTRY_HTTP_SECRET用于多个registry实例共享会话时保持一致,单实例场景下可以不设,但设了也没坏处。
还有一个容易被忽略的参数是REGISTRY_STORAGE_CACHE_BLOBDESCRIPTOR,它控制blob描述符的缓存方式。默认是inmemory,对于镜像数量多的场景,改成redis或者直接禁用缓存可以避免内存占用过高。我这边镜像数量在两千左右时,默认配置下容器内存会缓慢涨到1G以上,后来把缓存策略调整后才稳定在300M左右。
配置文件我习惯用YAML格式挂载进去,比一堆环境变量清晰得多。下面是一个精简后的配置示例:
version: 0.1 storage: filesystem: rootdirectory: /var/lib/registry delete: enabled: true cache: blobdescriptor: inmemory http: addr: :5000 secret: myregistrysecret headers: X-Content-Type-Options: [nosniff] health: storagedriver: enabled: true interval: 10s threshold: 3这个配置里health段是健康检查,配合容器的healthcheck指令使用,能及时发现存储层异常。headers段加上nosniff是安全加固的基本操作,防止内容类型嗅探攻击。
2.2 Nginx反向代理的配置细节
Nginx这层代理承担了TLS终止、认证转发、请求体大小限制等职责。配置里几个关键点必须注意。
client_max_body_size一定要调大,默认的1M对于镜像层来说完全不够。我一般设成0表示不限制,或者设成10g这样的值。这个参数如果忘了改,推送大镜像时会收到413错误,而且错误信息不太直观,容易让人以为是registry本身的问题。
proxy_read_timeout和proxy_send_timeout也要适当调大,镜像层传输时间可能较长,默认60秒在某些慢速场景下会超时。我设成900s,基本覆盖了所有正常情况。
认证部分用auth_basic配合htpasswd文件。需要注意的是,registry容器本身也可以配置认证,但既然Nginx已经做了,registry那边就可以不配,避免双重认证带来的混乱。htpasswd文件用htpasswd -Bc命令生成,-B表示用bcrypt加密,比默认的MD5安全得多。
upstream registry_backend { server 127.0.0.1:5000; } server { listen 443 ssl; server_name registry.internal.example; ssl_certificate /etc/nginx/certs/registry.crt; ssl_certificate_key /etc/nginx/certs/registry.key; client_max_body_size 0; chunked_transfer_encoding on; location / { auth_basic "Registry Auth"; auth_basic_user_file /etc/nginx/auth/htpasswd; proxy_pass http://registry_backend; 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; proxy_read_timeout 900; proxy_send_timeout 900; } }chunked_transfer_encoding on这一行对于大镜像的分块传输很重要,关掉的话某些客户端可能会遇到传输中断。X-Forwarded-Proto头要传对,否则registry生成的某些URL可能用错协议。
2.3 存储目录规划与容量估算
存储目录的规划直接影响后续运维的便利性。我的做法是在宿主机上单独挂一块数据盘,挂载到/data/registry,然后把容器的/var/lib/registry映射过去。这样做的好处是镜像数据和系统盘分离,系统盘满了不影响镜像服务,镜像盘满了也容易扩容。
容量估算有个经验公式:镜像总大小 ≈ 基础镜像大小 × 1.5 + 业务层大小 × 版本数。因为Docker的层是共享的,同一个基础镜像的多个版本只存一份基础层。假设团队有10个基础镜像各500M,业务层平均200M,每个业务镜像有20个版本,那么总容量大约是 10×500M×1.5 + 200M×20×10 = 7.5G + 40G = 47.5G。实际规划时留一倍余量,100G起步比较稳妥。
目录结构上,registry会在rootdirectory下自动创建docker/registry/v2/这样的层级,里面按仓库名和层哈希组织。不要手动去改这些目录,清理镜像要通过API或者工具来做。
提示:如果磁盘空间紧张,可以定期运行垃圾回收。但注意GC期间registry最好停止写入,否则可能误删正在使用的层。生产环境建议在低峰期做,或者用只读模式启动一个临时实例来执行GC。
3. 完整搭建流程与实操记录
3.1 环境准备与依赖安装
假设我们有一台内网服务器,IP是192.168.1.100,操作系统是主流的Linux发行版。第一步是安装Docker引擎和Compose插件。不同发行版的安装命令略有差异,但核心步骤一致:添加软件源、安装docker-ce和docker-compose-plugin、启动服务并设置开机自启。
安装完成后用docker version和docker compose version确认版本。我建议Docker版本不低于20.10,Compose版本不低于2.0,这两个版本之后的API和功能都比较稳定。
接下来创建目录结构:
mkdir -p /data/registry mkdir -p /opt/registry/nginx/certs mkdir -p /opt/registry/nginx/auth mkdir -p /opt/registry/config/data/registry放镜像数据,/opt/registry放配置和证书。这种分离让备份策略更清晰——数据目录定期快照,配置目录纳入版本管理。
3.2 生成自签名证书与认证文件
自签名证书用openssl生成。关键是要在-subj参数里把CN设成实际访问的域名,同时加上SAN扩展。很多教程只设CN不设SAN,在新版Docker客户端上会报错,因为现代TLS校验优先看SAN字段。
openssl req -x509 -nodes -days 3650 -newkey rsa:4096 \ -keyout /opt/registry/nginx/certs/registry.key \ -out /opt/registry/nginx/certs/registry.crt \ -subj "/C=CN/ST=Internal/L=Internal/O=Internal/CN=registry.internal.example" \ -addext "subjectAltName=DNS:registry.internal.example"-days 3650是十年有效期,内网证书不用频繁更换。-addext加上SAN,确保域名匹配。生成后用openssl x509 -in registry.crt -text -noout检查一下SAN字段是否正确。
认证文件用htpasswd生成。如果系统里没有这个命令,可以装apache2-utils或者httpd-tools包。生成时用-B参数走bcrypt:
htpasswd -Bc /opt/registry/nginx/auth/htpasswd myuser系统会提示输入密码。-c表示创建新文件,第二次添加用户时要去掉-c,否则会覆盖已有内容。这个细节虽小,但忘了的话会把之前加的用户全清掉。
3.3 编写Compose编排文件
用Compose把registry和Nginx编排在一起,管理起来最方便。文件放在/opt/registry/docker-compose.yml:
services: registry: image: registry:2.8 container_name: registry restart: always volumes: - /data/registry:/var/lib/registry - /opt/registry/config/config.yml:/etc/docker/registry/config.yml:ro environment: - REGISTRY_STORAGE_DELETE_ENABLED=true healthcheck: test: ["CMD", "wget", "-q", "--spider", "http://localhost:5000/v2/"] interval: 30s timeout: 5s retries: 3 networks: - registry-net nginx: image: nginx:1.25-alpine container_name: registry-nginx restart: always ports: - "443:443" volumes: - /opt/registry/nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro - /opt/registry/nginx/certs:/etc/nginx/certs:ro - /opt/registry/nginx/auth:/etc/nginx/auth:ro depends_on: - registry networks: - registry-net networks: registry-net: driver: bridge这里registry容器不暴露端口到宿主机,只在内网bridge网络里和Nginx通信,减少攻击面。Nginx暴露443端口对外。depends_on确保启动顺序,但要注意它只保证容器启动顺序,不保证服务就绪,所以healthcheck配合restart策略更可靠。
3.4 启动验证与客户端配置
配置写好后执行docker compose up -d启动。用docker compose ps查看状态,等healthcheck变成healthy。然后在本机测试:
curl -k -u myuser:password https://registry.internal.example/v2/返回{}就说明认证和代理都通了。如果返回401,检查htpasswd文件路径和权限;如果返回502,检查Nginx到registry的网络连通性。
客户端配置方面,需要把自签名证书放到Docker的信任目录。不同系统的路径不同,主流Linux发行版一般是/etc/docker/certs.d/registry.internal.example/ca.crt。把registry.crt复制过去并改名ca.crt,然后重启Docker服务。
mkdir -p /etc/docker/certs.d/registry.internal.example cp /opt/registry/nginx/certs/registry.crt /etc/docker/certs.d/registry.internal.example/ca.crt systemctl restart docker做完这一步,就可以正常docker login、docker push、docker pull了。第一次推送一个测试镜像验证全流程:
docker pull alpine:latest docker tag alpine:latest registry.internal.example/library/alpine:latest docker push registry.internal.example/library/alpine:latest推送成功后,在/data/registry/docker/registry/v2/repositories/下能看到对应的目录结构。
4. 常见问题排查与运维经验
4.1 推送拉取典型故障速查
实际运维中遇到的问题五花八门,我整理了一张速查表,覆盖了八成以上的常见故障:
| 现象 | 可能原因 | 排查方向 | 解决方法 |
|---|---|---|---|
| push报413 | Nginx请求体限制 | 检查client_max_body_size | 设为0或足够大的值 |
| push报证书错误 | SAN字段缺失或域名不匹配 | openssl检查证书 | 重新生成带SAN的证书 |
| pull报401 | 认证信息未传递 | 检查htpasswd和auth_basic | 确认密码正确、文件权限可读 |
| pull超时 | 网络或代理超时设置 | 检查proxy_read_timeout | 调大到900s |
| 磁盘满 | 镜像堆积未清理 | df -h查看使用率 | 启用删除并执行GC |
| 502错误 | Nginx到registry不通 | 检查容器网络和端口 | 确认在同一network |
| 层数据损坏 | 存储层异常 | 查看registry日志 | 从备份恢复或重新推送 |
这张表里的每一条我几乎都遇到过。最坑的是证书问题,因为Docker客户端的报错信息往往只说"certificate signed by unknown authority",不告诉你具体是CN不对还是SAN缺失。后来我养成了习惯,生成证书后一定用openssl x509 -text逐项核对。
4.2 镜像清理与垃圾回收实操
镜像越推越多,磁盘迟早会满。清理分两步:先删除仓库里的manifest,再执行GC回收未被引用的层。
删除manifest用registry的API:
# 列出仓库 curl -k -u myuser:password https://registry.internal.example/v2/_catalog # 列出某个仓库的标签 curl -k -u myuser:password https://registry.internal.example/v2/library/alpine/tags/list # 获取manifest的digest curl -k -u myuser:password -H "Accept: application/vnd.docker.distribution.manifest.v2+json" \ https://registry.internal.example/v2/library/alpine/manifests/latest # 删除指定digest curl -k -u myuser:password -X DELETE \ https://registry.internal.example/v2/library/alpine/manifests/sha256:xxxxx删除manifest只是标记,实际磁盘空间不会释放。需要进入registry容器执行GC:
docker exec registry registry garbage-collect /etc/docker/registry/config.ymlGC会扫描所有manifest,删除没有被引用的层。这个过程在镜像多的时候可能跑很久,而且会占用较多IO。我的经验是在业务低峰期做,并且先停掉写入——可以临时把Nginx的location返回503,或者直接停掉registry容器用另一个临时容器挂载同一数据目录来跑GC。
注意:GC有个已知行为,如果manifest被删除但标签还指向它,GC不会清理。所以删除时要确保标签也一并处理,或者用工具批量清理。我后来写了个脚本,先列出所有标签,对比保留策略,再逐个删除manifest和标签,最后统一GC。
4.3 性能调优与监控建议
跑了一段时间后,如果发现推送拉取变慢,可以从几个方向排查。首先是磁盘IO,registry对磁盘随机读写比较敏感,机械盘在镜像多的时候会明显变慢,换成SSD会有质的提升。其次是内存,前面提到的blobdescriptor缓存如果设成inmemory,镜像数量多时会吃内存,可以改成redis或者禁用。
监控方面,registry本身暴露了/debug/health和/debug/vars两个端点,前者返回健康状态,后者返回Prometheus格式的指标。用Nginx把这两个端点限制在内网访问,然后接入监控系统。关键指标包括请求延迟、存储使用量、GC执行时间。
我自己的做法是用一个简单的脚本每天定时检查磁盘使用率,超过80%就发告警,同时自动执行一次GC。这样基本不会出现磁盘写满导致服务不可用的情况。
4.4 备份策略与迁移方案
镜像数据虽然可以从代码重新构建,但重新构建的时间成本很高,所以备份还是有必要的。我的策略是每周对/data/registry做一次全量快照,保留最近四周。快照用文件系统级别的工具做,比逐个文件复制快得多,而且一致性有保证。
迁移到新服务器时,把数据目录和配置目录一起打包过去,在新机器上改一下域名解析和证书,启动Compose即可。注意迁移前最好停掉旧服务,避免数据不一致。如果无法停机,可以先做一次全量同步,然后停服务做增量同步,再切换。
证书迁移时记得新机器的证书CN要和访问域名一致,如果域名变了要重新生成。htpasswd文件可以直接复制,但密码是bcrypt哈希,复制后用户密码不变。
这套私有镜像库我们团队用了两年多,从最初的几十个镜像到现在两千多个,中间经历过磁盘满、证书过期、GC误删等几次小事故,但整体非常稳定。最大的体会是:简单方案加上规范的运维习惯,比复杂方案加上随意的操作要可靠得多。registry本身足够简单,把Nginx配置、证书管理、清理策略这几件事做扎实,它就能安安稳稳地跑下去。