我们团队去年把 CI 的构建产物从公网 Docker Hub 搬到了自建的私有仓库上,一开始觉得这事特别简单装个 registry 服务,建个账号,`docker push` 就完事了。结果上线第一周就接连踩了三个坑:第一次推送直接报 unauthorized: authentication required,第二次别的机器拉镜像卡到超时,第三次更狠,仓库所在的那台机器磁盘直接被撑到 100%,构建全停。回头看,这三件事表面症状完全不一样,根子却都在同一个地方:你以为装好了一个服务,其实你只是跑起来了一个没有任何约束的进程。这篇就把这三个坑的现场和处理过程完整还原一下。
如果你也在考虑给团队搭一套自己的镜像仓库,或者手上正好有几台闲置机器想利用起来,可以先看看这两家的机器做参考:RackNerd(全球VPS,特价常推)、狗云DOGYUN(高性价比云服务器,多机房可选),跑镜像仓库这类服务,磁盘和带宽比 CPU 更重要,选机器的时候别光盯着核心数看。

坑一:docker push 报 401 unauthorized,别急着怀疑账号密码
这个坑最气人。现象是:本地 docker login 明明提示 Login Succeeded,push 的时候却甩一句 unauthorized: authentication required。第一反应肯定是密码错了,于是一遍遍重输,一遍遍失败。
后来我用 docker push 的时候加了个 --disable-content-trust 又试,还是 401。再往下查才发现,问题出在客户端和服务端的 Registry API 版本对不上。老的 registry 服务只提供 v1 接口,而新一点的 Docker 客户端默认走 v2,推送的时候先去 v2 拿 token,没拿到就退回去用 v1 的老式登录,那个登录态本来就是空的,所以必然 401。
解决方式有两种:一种是给服务端的 registry 容器挂上 REGISTRY_STORAGE_DELETE_ENABLED=true 之类的兼容配置,让它同时暴露 v1;另一种更干净,直接在客户端和服务端都统一到 v2,并且在服务端加上 REGISTRY_AUTH=htpasswd,用 htpasswd -B 生成密码文件挂进去。我们选了后者,现在 docker login 和 push 都是一把过。
这里有个细节值得单独强调:htpasswd 的密码一定要用 -B(bcrypt)生成,不要用默认的 md5。md5 在现在的镜像拉取日志里一眼就能看出来,而且等于没加密任何一个能读到镜像仓库配置的人都能拿离线字典把你的密码还原出来。这不是危言耸听,Docker Hub 当年要求全面切 bcrypt,理由就在这。

坑二:别的机器拉镜像一直超时,本机却秒拉
第二个坑更隐蔽。仓库跑在本机 127.0.0.1:5000 上,push 上去是秒的,但同事那台机器 docker pull 192.168.1.50:5000/app:v1 就一直挂着,几十秒之后报 context deadline exceeded。第一反应是网络不通,可 ping 得通,curl 拉 manifest 也正常,就是 docker pull 卡住。
抓包看了下才发现,docker pull 的第一步是去拉 blob,也就是镜像层文件,每层都是一个独立的 HTTP 请求,会走 dockerd 的并发连接池。我们那台仓库机器的默认配置里,最大并发上传下载数被卡得很死,而同事的机器在同一个交换机上但走的路径不一样,TCP 握手排队排到超时为止。
处理办法是在仓库服务端的 registry 容器里加两个环境变量:REGISTRY_HTTP_MAX_INFLIGHT_REQUEST=64 和 REGISTRY_STORAGE_REDIRECT_DISABLE=true。第二个尤其关键默认情况下 registry 会返回 302 重定向,让客户端去后端存储服务器取文件,如果客户端和后端存储不在同一个网络命名空间里,这个重定向指向的地址客户端根本访问不到,表现就是一直转圈直到超时。关掉它之后,流量全部走仓库服务本身,虽然多了一层转发,但至少不会出现"能 ping 通却拉不下来"这种玄学问题。
顺带说一个当时差点让我们走弯路的经验:还有一种拉不下来的原因是本地缓存的 DNS 记录没更新。我们把仓库从 registry.local 迁到新机器,客户端 DNS 缓存了旧的 IP,结果访问的还是老机器。执行 docker system prune -a 清缓存加 systemctl restart docker 重启守护进程才恢复。所以下次再遇到拉不动,先 dig 一下,别一上来就改配置。

坑三:磁盘爆满,仓库自己把自己撑死
第三个坑是最贵的。那天早上 CI 全红,所有任务在 docker push 那一步卡住。登到仓库机器上一看,df -h 显示根分区 100%,/var/lib/registry/docker/registry/v2/blobs 这个目录吃掉了 180GB。
registry 有个非常要命的设计:你 push 上去的每一个 tag,只要还有任何一个 manifest 引用着对应的 layer,这些 layer 就永远不会被自动清理。我们 CI 每次构建都打一个新 tag(app:v1.0.0-20261102-abc123 这种),一天几百个镜像,几周下来磁盘就没了。
这一轮我们做了三件事,缺一不可:
第一,给 registry 加 GC(垃圾回收)。写个 garbage-collect.sh,先 --delete-untagged 清掉没有 tag 的游离层,再 --dry-run 预演一遍确认要删的东西,最后才真正执行。千万别跳过 dry-run 直接删,删错了就只能推倒重来。
第二,改 tag 策略。用 build-arg 把构建号写进 label,同时只保留 latest 和最近几个版本 tag,老 tag 推到第二天就用定时任务清掉。这一步让磁盘增速从每天 6GB 降到 200MB 以内。
第三,把仓库放到独立的数据盘上,别和系统盘混在一起。我们在 /data 挂了一块 500GB 的云硬盘做 /var/lib/registry,并配了 XFS 加 fuse-overlayfs 存储驱动这个驱动对大文件的读写表现明显好于默认的 overlay2,我们实测 push 一个 1.2GB 的镜像,用默认驱动要 4 分 10 秒,换成 fuse-overlayfs 之后压到 2 分 45 秒左右,CI 排队时间直接砍掉三分之一。

如果只让我留三条经验
折腾了一周多,把血泪压成三句话:
一、认证和版本问题必须在第一天就统一。服务端和客户端都锁到 Registry API v2,密码一律 bcrypt,不要指望默认配置能跑通,也别用 --disable-content-trust 这类参数去糊弄问题。
二、仓库服务的网络参数一个都不能省。REGISTRY_HTTP_MAX_INFLIGHT_REQUEST 和 REGISTRY_STORAGE_REDIRECT_DISABLE 这两个必须显式设置,迁移机器的时候还要把 DNS 缓存一起清掉,不然同事一拉就超时。
三、把镜像仓库当成一个有生命周期的服务来管。独立数据盘 + GC 定时任务 + tag 数量上限,三件套缺一不可。只 push 不清理的仓库,本质上就是一颗定时炸弹,指不定哪天磁盘就满了。
最后补一句题外话:自建镜像仓库最大的收益其实不是省流量,而是构建的可复现性。CI 打出来的那个镜像,就一直躺在你自己的服务器上,谁拉都是同一份 bit,不会出现"公网上被人重新 tag 了一层一样名字的镜像"这种恶心事。基础设施这种东西,坑都是要踩一遍才踏实,与其等出事再补,不如现在就按上面这三条把配置写进部署脚本里。


喜欢
顶
难过
囧
围观
无聊



