1. 自建镜像仓库的动机与边界
私有 Docker Registry 这个词,我在过去几年里被问过不下二十次。有人是被公共镜像托管服务的拉取频率限制卡住了脖子,流水线一跑就是几十个节点同时拉取;有人是内网环境根本出不去,构建机与外网完全隔离,镜像只能靠 U 盘来回拷;还有人遇到的是更隐蔽的问题——某次线上回滚,发现要用的那个镜像版本在公共仓库里被覆盖了,tag 还在,内容已经不是当初那一份了。这几种场景,归根到底指向同一件事:把容器镜像的分发链路,从别人手里拿回到自己手里。
所谓私有 Docker Registry,本质上就是一个遵守容器镜像分发协议(Distribution API)的 HTTP 服务,它负责接收docker push上传的镜像层,按仓库名和 tag 组织存放,响应docker pull与 manifest 查询请求。它能做的事情很朴素:给镜像一个稳定、内网可达、权限可控的落点。它解决的不是"能不能跑容器"的问题,而是"镜像从哪来、谁能推、谁能拉、留多久、怎么不留垃圾"这一整套供应链问题。适合谁来参照这份笔记:团队里负责构建流水线、负责基础环境、或者手上有几台机器想先跑起来试试的开发者;对容器有基本操作经验就够,不需要提前懂 Go 或者看源码。
我先把边界划清楚,因为很多坑来源于期望错位。纯 Registry 只做存取,它不做 Web 界面、不做用户组、不做漏洞扫描、不做镜像签名验证的策略拦截、也不做跨实例的自动同步。它像一个只提供 HTTP 接口的对象存储门面,剩下的东西得你自己搭。想清楚这一点以后,选型就不会纠结:要的是"够用的存储与分发",还是"带完整权限体系和界面的一体化平台"。这两者的运维成本差一个数量级,我见过太多团队一上来就堆一个重型平台,结果一年下来只用到 push 和 pull 两个动作,反而因为组件太多、依赖太杂,出问题时排查链路长得让人崩溃。
1.1 三个反复出现的真实痛点
第一个痛点是拉取的稳定性。公共镜像托管服务对匿名拉取有频率限制,这个限制是按来源 IP 统计的,落到企业出口那条共享带宽上,意味着构建流水线的高峰期很容易触发限流,报错就是toomanyrequests,重试几次可能好,也可能一直不好。构建机被卡住的时候,整个发布窗口都要跟着推迟。自建以后,这个限制就变成了你自己磁盘和网卡的问题,能不能扛住是容量规划问题,不是别人的配额问题。
第二个痛点是内容的不可变性与可追溯。公共仓库里的 tag 是可以被覆盖的,镜像的所有者随时可以推一个新的覆盖掉旧的。对于生产环境来说,这是个相当大的隐患:你的部署清单里写的是myapp:stable,三个月后重拉,拿到的可能是一个完全不同的东西。私有仓库里我们可以强制一套规范——生产用的镜像只能用 digest 引用,或者用"版本号+短提交号"构成不可变 tag,且推上去之后不允许覆盖,需要新版本就换 tag。要真正做到"不覆盖",还需要仓库侧配合删除权限的收紧,这个后面会讲。
第三个痛点是带宽与分发效率。同一个基础镜像层,在几十个服务的镜像里都出现,从外网拉一次和拉一百次,耗时可差两个数量级。Registry 的存储模型天然按内容寻址(content-addressable),相同的层只存一份,内网再拉取时走的是局域网带宽。我实测过一个具体场景:二十个服务镜像,总逻辑大小约 9GB,去重之后仓库实际占用 3.2GB 左右,首次全量拉取对一台内网机器来说是从十几分钟的等待变成两分多钟,而且不再依赖出口网络状况。
1.2 该放什么、不该放什么
我给自己定的规矩是:只放"构建产物"和"上游镜像的内网快照",不放任何密钥、配置文件、数据文件。很多人在打包时图省事,把.env、证书、私有 pip 源凭据塞进镜像层,然后推到仓库里。镜像层是不可变的,你把凭据从后一层删掉,它仍然留存在前面的层里,任何人拉到镜像后用docker save或者直接读层内容都能拿到。仓库加了认证也不等于安全,因为能拉的人本来就多。
另一条规矩是控制"上游镜像快照"的数量。早期我喜欢把用到的每个上游镜像都pull下来再tag再push到内网仓库,后来发现仓库里堆了几百个 tag,一半是三个月没人碰的。正确的做法是只镜像当前实际依赖的版本,并且定期用脚本比对"哪些 tag 在近 90 天内被拉取过",没有访问记录的进入淘汰清单。这个动作看着琐碎,但能避免仓库磁盘以一种润物细无声的方式被吃满,而磁盘满导致的 push 失败,往往是在发布最紧张的时候暴露的。
还有一类内容我坚决不放:构建缓存。有些流水线会把中间层镜像也推上去做缓存复用,这在大型项目里确实能省时间,但它让仓库的容量增长曲线变得不可预测。如果确实需要缓存复用,我会单独开一个仓库名前缀,比如cache/,并且在清理策略上和正式镜像完全隔离,容量单独设阈值。
1.3 三种落地形态的取舍
| 方案形态 | 组成 | 上手成本 | 适合场景 | 主要短板 |
|---|---|---|---|---|
| 单机最小可用 | Registry 容器 + 本地磁盘 + htpasswd | 半天内可跑通 | 小团队、内网实验、流水线初建 | 无高可用,磁盘单点 |
| 前置网关型 | Registry + Nginx/网关做 TLS 与访问日志 | 一到两天 | 需要统一入口、需要审计日志 | 仍需自己补备份与清理 |
| 一体化平台型 | 带权限、复制、扫描的重型组件集 | 一周以上,需专人维护 | 多团队、多集群、合规要求高 | 组件多,故障面大,升级风险高 |
我一般的建议是从第一档起步,把镜像命名的规范和清理脚本先跑顺,等确实出现了"多团队互相覆盖"、"需要跨机房复制"这类具体需求,再往上加。先搭重型平台再补规范,通常会变成"平台功能一大堆,实际行为没人说得清"。
2. 私有 Registry 的存储模型与运行骨架
很多人用 Registry 用了很久,但对它的存储结构没有概念,结果一遇到磁盘告警、删除不生效、GC 之后空间没释放这几个问题就抓瞎。这一节把模型说清楚,后面所有运维动作都能对上号。
Docker 分发协议里有两个核心概念:blob和manifest。blob 是内容块,可能是镜像的某一层(layer),也可能是配置 JSON。每个 blob 用它内容的 SHA256 摘要作为唯一标识,摘要一样就是同一份内容,所以天然去重。manifest 是一份清单,它记录了"这个镜像由哪些 blob 组成、各自的摘要和大小、以及配置 blob 是谁"。tag 则是一个可变的指针,指向某个 manifest 的摘要。docker pull myapp:1.2.0的过程是:先按仓库名和 tag 拿到 manifest,再按 manifest 里列出的摘要逐个去取 blob,每取到一个都做一次摘要校验,对不上就报错。这个校验机制就是为什么镜像传输中途损坏不会静默通过——它一定会报digest mismatch或者解压失败。
理解了这一层,几个常见困惑就自解了。为什么同一个基础镜像占用的空间不随镜像数量线性增长?因为层内容相同,摘要相同,物理上只有一份。为什么删了 tag 磁盘没变?因为 tag 只是个指针,删指针不动 blob,blob 要靠垃圾回收才会真正消失。为什么 GC 之后空间还是没释放多少?因为还有别的 manifest 在引用那些层,引用计数不为零就不能删。
2.1 磁盘布局与容量估算的做法
文件系统存储驱动下,典型目录结构是这样:根目录下有个docker/registry/v2/,里面blobs/sha256/存放所有内容块(按摘要前两位分目录),repositories/<仓库名>/_manifests/下按 tag 和 revision 存放 manifest 引用,_layers/下是层与仓库的关联链接。这里的"链接"是普通的空文件,靠路径表达引用关系——所以备份时必须保留目录结构,不能只拷 blob 内容,否则引用全丢。
容量估算我用的公式是这样:设基础镜像层集合去重后大小为B,团队有N个服务镜像,每个服务在基础层之上独占的层合计约S,每个服务保留的历史版本数为V,则粗略占用 ≈B + N × S × V × 0.6。最后那个 0.6 是经验系数,因为同一服务的多个版本之间往往也共享大量层,实测比线性增长要缓。举个具体数:B取 900MB(一个精简运行时加常用系统包),N=30,S=120MB(业务代码与依赖),V=5,算下来约 900MB + 30×120MB×5×0.6 ≈ 11.7GB。但实际跑三个月后我看过一眼,是 8GB 出头。做规划时我一般按公式值再乘 1.5 留余量,因为临时调试用的镜像、CI 中间产物、误推的东西都会偷偷加进来。
注意:不要把仓库根目录放在系统盘。磁盘写满时如果系统盘就是数据盘,docker 守护进程自己都可能起不来,连排查的机会都没了。单独挂一块盘,或者至少单独分一个卷,配一个 80% 水位的告警。
2.2 认证与加密的最小组合
生产环境我从来不用"裸 HTTP + 无认证"的组合,哪怕它只在内网。原因不是怕外部攻击,而是怕内部误操作:没有认证,任何能访问到端口的机器都能往里推东西,一个写错地址的流水线就能把测试镜像推成生产 tag。最小的安全组合是HTTPS + basic auth,两者都不复杂。
证书有两种走法。有内部域名和签发通道的,直接申请正式证书,省心。没有的话就用自签证书,但必须注意:自签证书的 SAN(subjectAltName)里要把客户端实际访问用的域名和 IP 都列上,只写 Common Name 是不够的,现代客户端的严格校验会直接拒绝,报错是x509: certificate is valid for X, not Y。我踩过这个坑,当时证书里只写了主机名,结果用 IP 访问的所有构建机全部失败,排查了一圈才发现是 SAN 不匹配。
认证用 htpasswd 文件最省事,Registry 原生支持。密码必须用 bcrypt 格式生成,用普通的 MD5 或者 crypt 格式生成出来,Registry 会认证失败而且报错信息不明显,只返回 401,很容易被误判成"密码错了"。生成命令后面会写。这个方案的局限是只有"用户名密码"这一维,没有细粒度的读/写权限区分——能认证的人都能推。团队规模小的时候够用;一旦出现"外包人员只能拉不能推"的需求,就得在前面加一个能按 HTTP 方法做鉴权的网关,或者换带权限模型的一体化方案。
2.3 三个必须提前定好的参数
第一个是监听地址与端口。默认 5000,容器内监听的地址建议显式写成0.0.0.0:5000,否则在某些网络模式下只绑 IPv6 或者只绑回环。端口映射到宿主机时也别用 5000 直接对外,前面套一层网关用 443 更规范,客户端配置也简单。
第二个是存储的delete_enabled。这个开关默认是关的,意味着即使你调删除 API 也会被拒绝。很多人以为删除失败是权限问题,其实是没开这个开关。开了之后删除仍然只是删引用,空间要等 GC,这两步是分开的,必须都做。
第三个是单次请求的最大体积与超时。大镜像层上传时,前置网关如果默认限制请求体大小(常见是 1MB 或者 10MB),会直接返回 413,现象是docker push推到某个层突然中断。网关侧要把请求体限制关掉(设成 0 表示不限),并且关闭请求缓冲,让数据流式转发,否则大层会在网关侧先落盘一份,既慢又吃磁盘。
3. 从零到可用的完整部署过程
这一节是能直接照做的那种。我假设你有一台内网 Linux 机器,已经装好容器运行时,有 root 或者 sudo,能访问到镜像源。下面所有路径和参数都是我在多个环境里用过的稳定组合,你按自己的域名和端口替换即可。
3.1 前置检查清单
先花五分钟把这几项确认掉,能省掉后面一小时的瞎猜。
- 容器运行时版本:
docker version看客户端与服务端都能通。 - 磁盘:
df -h确认数据盘挂载点,预留至少 50GB 起步。 - 端口:
ss -lntp | grep 5000确认没被占。 - 域名解析:客户端机器上
getent hosts registry.internal.example能解析到目标 IP。这里用示例域名,你换成自己的内部域名。 - 时间同步:证书校验依赖时间,节点时间偏差太大会报证书过期或者未生效。
我还会提前把目录建好并设权限:/data/registry(数据)、/data/registry-auth(认证文件)、/data/registry-certs(证书)。认证文件目录权限收一下,只给运行容器的用户读,chmod 700比较稳妥。
3.2 生成证书与认证文件
自签证书一条命令搞定,关键是-addext里把 SAN 写全。下面这条生成的是带 SAN 的自签证书,有效期 825 天:
mkdir -p /data/registry-certs && cd /data/registry-certs openssl req -x509 -nodes -newkey rsa:4096 -days 825 \ -keyout registry.key \ -out registry.crt \ -subj "/CN=registry.internal.example" \ -addext "subjectAltName=DNS:registry.internal.example,IP:10.20.30.40" chmod 600 registry.key生成完用这条自检一下,确认 SAN 里有你要用的地址:
openssl x509 -in registry.crt -noout -text | grep -A1 "Subject Alternative Name"认证文件用 httpd 镜像里的 htpasswd 工具生成,注意-B是 bcrypt,千万别漏:
mkdir -p /data/registry-auth docker run --rm --entrypoint htpasswd httpd:2 \ -Bbn deploy_user '换成你的强密码' > /data/registry-auth/htpasswd # 追加第二个用户时用 -B(不带 n,表示写入文件) docker run --rm -v /data/registry-auth:/auth --entrypoint htpasswd httpd:2 \ -Bb /auth/htpasswd ci_user '另一个强密码'注意:
-bn生成到标准输出的写法在部分 shell 下会被重定向吃掉换行,导致文件里多条记录粘在一行,认证一定失败。追加用户时尽量用带-v挂载目录的写法,直接在容器内写文件。
3.3 Registry 配置与启动
我不用一堆环境变量拼配置,而是写一个完整的config.yml,可读性和可维护性都好得多,版本也能进版本库。
version: 0.1 log: level: info fields: service: registry storage: filesystem: rootdirectory: /var/lib/registry delete: enabled: true cache: blobdescriptor: inmemory maintenance: uploadpurging: enabled: true age: 168h interval: 24h http: addr: 0.0.0.0:5000 headers: X-Content-Type-Options: [nosniff] http2: disabled: false auth: htpasswd: realm: internal-registry path: /auth/htpasswd health: storagedriver: enabled: true interval: 10s threshold: 3uploadpurging这段值得单独说:客户端上传中断(网络抖、CI 容器被杀)会留下一堆没完成的上传临时文件,默认策略会清理,但如果你把这项关掉或者把age设得很大,这些碎片会一直占空间。我把它设成 7 天,每天扫一次,省心。
对应的 compose 文件:
services: registry: image: registry:2 container_name: registry restart: always ports: - "10.20.30.40:5000:5000" volumes: - /data/registry:/var/lib/registry - /data/registry-auth:/auth:ro - /data/registry-certs:/certs:ro - ./config.yml:/etc/docker/registry/config.yml:ro environment: - TZ=Asia/Shanghai healthcheck: test: ["CMD", "/bin/registry", "serve", "/etc/docker/registry/config.yml"] interval: 30s注意ports里我绑了具体的内网 IP,而不是0.0.0.0,这在有多网卡的机器上能避免意外暴露到不该通的网段。启动后先本地验一下:
docker compose up -d curl -k -u deploy_user:密码 https://registry.internal.example:5000/v2/ # 期望返回 {}如果返回 401 且WWW-Authenticate头存在,说明认证生效了但你没带凭据;返回{}说明认证通过。这个/v2/端点同时也是最好的健康检查目标——它活着,服务基本就是活的。
3.4 客户端打通:证书与 insecure 的取舍
客户端要信任这张自签证书。最规范的做法是把registry.crt分发到每台机器的/etc/docker/certs.d/registry.internal.example:5000/ca.crt,注意目录名必须带端口,少了端口 docker 找不到。分发完不需要重启 docker,直接docker login就行。
另一种做法是在/etc/docker/daemon.json里把地址加进insecure-registries,跳过 TLS 校验。我把它当作过渡手段:新环境先这样跑通流程,然后在同一周内把证书补上。长期用 insecure 的代价不只是"不加密"——它让中间人替换镜像层成为可能。虽然层有摘要校验,但 manifest 本身也是可以被伪造的,最终你拉到的就是一个内容一致但来源不对的镜像集合。改daemon.json之后必须重启 docker 服务,很多人改完直接 login,报的还是老错误,就是忘了重启。
{ "insecure-registries": ["registry.internal.example:5000"], "max-concurrent-downloads": 6 }max-concurrent-downloads这个参数在内网拉大镜像时提一下有用,默认 3 有时候跑不满带宽。
3.5 第一次推拉验证与命名规范
流程验证我按这个顺序走,每步都能快速定位问题出在哪一层:
# 1. 登录 docker login registry.internal.example:5000 -u deploy_user # 2. 从小镜像开始,减少变量 docker pull alpine:3.19 docker tag alpine:3.19 registry.internal.example:5000/base/alpine:3.19 # 3. 推送并看分层进度 docker push registry.internal.example:5000/base/alpine:3.19 # 4. 换一台机器(或先本地删掉)拉取 docker rmi registry.internal.example:5000/base/alpine:3.19 docker pull registry.internal.example:5000/base/alpine:3.19命名规范我建议在第一天就定死,因为后期改名要动所有流水线。我的习惯是仓库地址/项目组/服务名:tag,tag 用三段式主版本.次版本.构建号,另外允许一个sha-<短提交号>形式的不可变 tag 供生产引用。生产部署的清单里只用后者,不用latest,也不用浮动 tag。这条规矩执行起来有点烦,但它把"回滚时拿到的东西和当初一模一样"变成了默认行为,而不是靠人记性。
4. 长期运行的维护动作
跑起来只是开始,真正花时间的是后面这些:删镜像、回收空间、备份、看日志、控容量。这一节按我实际的维护节奏来写。
4.1 删除镜像与垃圾回收的正确顺序
因为 tag 只是指针,删除必须走 API,而不是"进目录 rm -rf"。直接删目录会导致元数据与 blob 不一致,后续 push 同名镜像可能出现奇怪的冲突。正确流程分三步。
第一步,拿到 manifest 的 digest。注意Accept头必须指定,否则可能拿到 v1 格式或者被拒:
curl -s -k -u deploy_user:密码 \ -H "Accept: application/vnd.docker.distribution.manifest.v2+json" \ -I https://registry.internal.example:5000/v2/myteam/myapp/manifests/1.2.0 \ | grep -i docker-content-digest第二步,用 digest 删除:
curl -s -k -X DELETE -u deploy_user:密码 \ https://registry.internal.example:5000/v2/myteam/myapp/manifests/sha256:xxxx # 期望 202 Accepted第三步,停机做 GC。GC 必须让仓库处于只读或停止状态,否则并发写入可能被误判为无引用:
docker compose stop registry docker run --rm \ -v /data/registry:/var/lib/registry \ -v $(pwd)/config.yml:/etc/docker/registry/config.yml:ro \ registry:2 garbage-collect --delete-untagged /etc/docker/registry/config.yml docker compose start registry--delete-untagged会把所有没有 tag 指向的 manifest 一并清掉,能明显多回收空间,但要小心:如果你的流程里有"先推一个 sha tag,之后用另一个 tag 指向同一个 manifest"的操作,中间窗口做 GC 可能误删。我的做法是 GC 只在固定的维护窗口做,且做之前确认没有正在跑的流水线。GC 时间跟仓库规模相关,10GB 级别的仓库一般一两分钟,上百 GB 的可能要十几分钟,期间服务不可用,得安排在低峰。
4.2 备份策略与一次真实的恢复演练
Registry 的数据是"元数据 + blob",两者必须一致,所以备份不能只拷 blob。我用的方案是文件级冷备:先停服务,用tar打包整个根目录(保留硬链接和目录结构),再启动。增量场景下 rsync 也行,但必须保证引用文件一起同步。
docker compose stop registry tar -C /data -czf /backup/registry-$(date +%F).tar.gz registry docker compose start registry频率取决于镜像更新节奏,我服务的团队一般一周一次全量,保留最近四周。备份文件放另一台机器,别放在同一个盘上——这条不用解释。
恢复演练我建议至少做一次,因为它会暴露几个真实问题:一是只恢复 blob 目录不恢复repositories/下的引用文件,仓库会显示空目录;二是恢复后htpasswd和证书如果不在备份范围内,服务起不来;三是权限位丢失导致容器内进程读不了文件。我第二次演练就栽在第三条上,解包后所有文件属主变成了解包用户,容器里那个非 root 用户读不了,日志里报permission denied,当时还以为是数据损坏。
4.3 前置网关:TLS 卸载、日志与限流
把网关放在 Registry 前面,好处有三个:统一入口与正式证书、拿到清晰的访问日志、能按路径做粗粒度限流。配置要点不多,但每个都不能漏。
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; # 不限请求体,大层必须 proxy_request_buffering off; # 流式转发,避免网关落盘 proxy_buffering off; chunked_transfer_encoding on; location /v2/ { proxy_pass http://127.0.0.1:5000; proxy_set_header Host $http_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 900s; proxy_send_timeout 900s; } }proxy_set_header Host $http_host这一行不能省成$host,因为$host不带端口,而 Registry 在做重定向(比如分片上传返回的 location)时依赖原始 Host 拼地址,搞错了客户端会去一个错误地址继续上传,现象是 push 卡在某一层不动,超时后失败。
日志里我会定期看两类:一是 401 的分布,某个 IP 401 特别多通常是密码过期或者流水线里的凭据写错了;二是出现 413、499 的次数,413 说明还有地方没放开请求体限制,499 是客户端主动断开,多半是超时设太短。这些统计不需要复杂工具,grep + awk按天聚合就够用。
4.4 容量监控与清理自动化
监控我只要三个指标,用最简单的脚本实现:
| 指标 | 采集方式 | 告警阈值 | 触发后的动作 |
|---|---|---|---|
| 磁盘使用率 | df取数据盘 | 80% 预警 / 90% 严重 | 跑清理脚本 + 通知 |
/v2/可用性 | curl 带凭据请求 | 连续 3 次失败 | 检查容器与网关 |
| 上传失败数 | 网关日志聚合 4xx/5xx | 单日超过阈值 | 查流水线凭据与超时 |
清理脚本的逻辑很简单,但有几个细节要处理。先按仓库列举 tag,取每个 tag 最后一次被访问的时间(这个信息纯 Registry 不提供,得从网关日志里捞,或者干脆用"镜像创建时间"近似),超过 N 天没有访问记录的列入候选。清单先输出不删,人工扫一眼确认,再执行删除。我给自己加了一条硬规则:prod/前缀的镜像禁止自动删除,只能人工处理。这条规则救过一次急,脚本逻辑写错的时候把某个前缀匹配错了,因为排除了生产前缀,最坏情况没有发生。
自动清理的另一个细节是不要一次删太多。GC 是全量的、要停服务的,如果一次删了 200 个 tag,GC 时长得让人焦虑。分批删,每次几十个,维护窗口内完成。
5. 常见问题与排查实录
这一节是我这几年攒下来的对照表,大部分问题看报错信息就能定位。先说一个通用思路:push/pull 失败时,先判断是"认证层"、"传输层"还是"存储层"的问题。认证层报 401/403;传输层报 TLS 或者连接超时;存储层报 blob unknown、digest 不匹配、磁盘满。按这个分类走,比漫无目的地看日志快得多。
5.1 常见报错速查表
| 报错信息片段 | 真实原因 | 处理方式 |
|---|---|---|
http: server gave HTTP response to HTTPS client | 客户端用 https 访问了一个只提供 http 的服务,或证书未受信任 | 配置证书,或临时加入 insecure-registries 并重启 docker |
x509: certificate signed by unknown authority | 客户端未信任自签 CA | 把 ca.crt 放到/etc/docker/certs.d/<域名:端口>/ |
x509: certificate is valid for A, not B | 证书 SAN 里没有当前访问用的地址 | 重新签发,SAN 写全域名与 IP |
no basic auth credentials/ 反复 401 | htpasswd 密码不是 bcrypt,或-bn写入时记录粘连 | 用-B重新生成,检查文件行数 |
blob unknown to registry | manifest 引用的层在上传阶段中断,元数据与内容不一致 | 重新 push;检查 uploadpurging 配置与磁盘空间 |
413 Request Entity Too Large | 网关默认请求体限制 | 网关client_max_body_size 0 |
received unexpected HTTP status: 500出现在 push 末尾 | 磁盘写满或 GC 期间写入 | 看磁盘水位,检查是否在 GC 窗口内操作 |
manifest unknown | tag 不存在,或该镜像已被删除 | 列 tag 确认,/v2/<name>/tags/list |
| pull 成功但容器起不来,报解压失败 | 镜像层损坏或传输中断 | 删本地镜像重拉;检查仓库侧 blob 完整性 |
5.2 几个值得单独讲的坑
第一个坑:push 到一半卡住,没有任何报错。现象是进度条停在某个层,十分钟后超时。排查顺序是:先看网关日志有没有对应请求,如果没有,说明请求卡在客户端到网关这一段,通常是 MTU 或者中间设备的问题;如果有请求但长时间无响应,看proxy_request_buffering是否关了,以及 Registry 容器的磁盘 IO 是不是被打满。我遇到过一次是备份任务和 push 撞在一起,备份把磁盘 IO 吃满了,push 的写入被拖到超时。后来把备份挪到了更深的时间段。
第二个坑:GC 之后空间没释放,甚至 service 起不来。前者通常是别的 manifest 还在引用那些层,属于正常;后者多半是 GC 过程中被中断,留下了不一致状态。我的处理办法是先确认服务能正常读,能正常 push/pull 就先不动,然后在一个维护窗口里重跑一次完整 GC。如果实在起不来,用备份回滚,别硬修——存储层的元数据手工修复的成功率不高,代价却是时间。
第三个坑:多台构建机,只有一台登录成功。九成是证书目录名少了端口。/etc/docker/certs.d/下的目录必须严格等于"域名:端口",写成registry.internal.example而不是registry.internal.example:5000,docker 就不会去读里面的证书,然后回退到系统信任链,失败。这个错误没有任何提示,非常隐蔽,我第一次遇到时查了快两个小时。
第四个坑:tag 被覆盖导致回滚异常。有人在流水线里用固定 taglatest或者dev,两个流水线并行跑的时候互相覆盖,回滚时拉到的不是预期版本。这个没法靠 Registry 拦,只能靠规范:生产环境引用 digest,tag 不允许覆盖(可以配一个校验步骤,push 前先查这个 tag 存不存在,存在就失败)。这条规矩的价值在事故复盘时才体现得最明显。
5.3 我个人的几条实操心得
第一条,永远先在小镜像上验证链路。alpine几百 KB,从 push 到 pull 走完只要几秒,任何配置问题都能立刻暴露。用几百 MB 的业务镜像去调环境,光是等传输就耗掉耐心,还容易把传输问题误判成配置问题。
第二条,把证书有效期写进日历。自签证书过期那天,所有构建机同时失败,报错是certificate has expired,场面很难看。我现在的做法是签发时就用 825 天,同时在到期前 60 天设一个提醒,提醒内容里直接写清楚重新签发的命令,免得到时候还要翻记录。
第三条,仓库的日志一定要留够时间。排查"某个镜像什么时候被谁推的"这类问题时,容器日志经常被覆盖,网关的访问日志是最可靠的证据来源。至少留 30 天,按天切割加压缩,占用不大。
第四条,别在没有验证过恢复流程的情况下认为备份是有效的。我见过备份任务每天成功、文件也在,但从来没解包过,真出事的时候才发现打包时用了排除规则,把元数据目录排掉了。抽半小时做一次恢复演练,比每天看备份成功的日志更让人安心。
第五条,把"镜像仓库不可用"当成一个需要预案的故障。它一旦挂了,所有依赖新镜像的发布都会停。预案很简单:本地保留最近几个生产版本镜像,或者保持一个只读的备用仓库地址,客户端配置里能快速切换。这个准备成本很低,但真出事的时候能让你从容很多。