凌晨三点十七分,CI 流水线在"拉取基础镜像"这一步红了。日志最后一行是 toomanyrequests,重跑一次,还是红的。这种时刻没什么戏剧性,就是一堆构建在排队等一个注册表的额度,而额度是按六小时的时间窗口配给的。它专挑发布前夜、专挑没人值班的时候出现,因为那时候同一批出口地址上的夜间批量构建和别的团队流水线,刚把最近六个小时的配额花得差不多了。更糟的是,流水线为了抢时间往往开并行任务,一个 matrix 里八个 job 同时去拉同一个基础镜像,配额瞬间烧掉八份。重试不会让墙变矮,只会让下一次撞得更响。
用 RustFS 做存储层的团队,撞上这类告警的概率天然低一些。原因不复杂:RustFS 的官方容器镜像不只发布在 Docker Hub,同一份镜像同步发布在 GHCR 和 quay.io 上,换一个 pull 地址就绕开了那扇被挤爆的门。
toomanyrequests 到底在抱怨什么
先把这个报错的来路说清楚。Docker Hub 对拉取的计量按 manifest 算,不是按"下载了多大"算:一个多架构镜像一次 pull 会解析出多个架构 manifest,每种各算一次,所以一个 CI job 里看似只拉了一次的镜像,配额可能被扣掉两三次。
更麻烦的是计费对象。未认证用户的配额按 IPv4 地址或 IPv6 /64 子网计算。个人开发者的家用宽带无所谓,CI 平台的出口 IP 是共享的:GitHub Actions、GitLab CI、各类托管构建服务的成千上万个用户,从同一批地址向 Docker Hub 发起拉取。Docker 官方文档专门提醒过这一点,第三方平台即使用户已认证,共享地址上的拉取也可能触发滥用限速。你的配额,别人在替你花。
错误信息本身也分两种。普通的拉取限速会返回一段较长的错误,里面附上说明文档的链接;滥用限速则是一个干脆的 429 Too Many Requests。半夜看日志时可以靠这个区分自己是撞了哪种墙。
重试是很多脚本的本能反应,在这里却是毒药。匿名配额按时间窗口恢复,撞墙之后立刻重跑,用的还是同一个地址、同一份额度,唯一的结果是把恢复时间往后推。看清报错、搞懂计量规则,比把重试次数从三次改成五次有用得多。
现行限额是多少
按 Docker Hub 官方文档当前的写法,各身份的拉取限额如下。
| 用户类型 | 每 6 小时拉取限制 |
|---|---|
| 未认证用户 | 100 次,按 IPv4 地址或 IPv6 /64 子网计 |
| Personal,已认证 | 200 次 |
| Pro、Team、Business,已认证 | 不限,公平使用约束 |
这组数字并不是什么新政策,限流本身不是新闻,只是隔一阵就会被社区拎出来讨论一轮。2025 年 4 月 8 日 Docker 更新的那则公告写得很明白:当时原定 4 月 1 日生效的调整并未执行,未认证用户 100 次、Docker Personal 用户 200 次的现行限额维持不变,付费订阅者享受不限量拉取。公告同时承诺,任何进一步的变化会至少提前六个月通知。
对个人开发者,200 次看起来够用。但在共享出口 IP 的 CI 环境里,这两百次不是你的两百次,是整个地址后面所有流量加起来的两百次。这就是为什么限流打击最重的从来不是个人笔记本,而是流水线。
一百次能撑多久
算一笔账就清楚了。RustFS 的官方镜像本身就是 amd64 和 arm64 双架构(发布流程里的 DOCKER_PLATFORMS 写得明明白白),一个 CI job 既要拉多架构的基础镜像、又要拉多架构的 RustFS 本体,各按两种架构计两次,一个 job 四次起步。矩阵并行十个 job,一轮就是四十次。共享出口 IP 上两个团队各自跑上两三轮,一百次的窗口就见底了,而这往往发生在一个上午之内。
同一套脚本,有人用了半年没事,有人第一天就撞墙,差别不在脚本写得好坏,在于那个 IP 地址后面站着多少人。把这笔记清楚,也就明白了换地址为什么是最省事的解法:它不减少拉取次数,只是换到一个没有这堵墙的地方。
RustFS 的三扇门
打开 RustFS 仓库的镜像发布流程,会发现它把同一份镜像推到三个注册表。.github/workflows/docker.yml里定义了三个目标:Docker Hub 的rustfs/rustfs、GHCR 的ghcr.io/rustfs/rustfs、quay.io 的quay.io/rustfs/rustfs。一次构建,同一组 tag 写入三处。
对照表如下,三条命令指向同一份镜像。
| 注册表 | 完整路径 | 拉取命令 |
|---|---|---|
| Docker Hub | docker.io/rustfs/rustfs | docker pull rustfs/rustfs:latest |
| GHCR | ghcr.io/rustfs/rustfs | docker pull ghcr.io/rustfs/rustfs:latest |
| quay.io | quay.io/rustfs/rustfs | docker pull quay.io/rustfs/rustfs:latest |
发版时的 tag 逻辑在 workflow 里写得清楚,而且分三种情况,全都同时写入三个注册表:稳定版发版本 tag 和 latest;预发布(alpha、beta、rc)发版本 tag 加对应的通道 tag(alpha/beta/rc),GA 之前还会顺带更新 latest;预览版(形如 1.0.2-preview.N)只发版本 tag,不挂通道 tag、也不动 latest,workflow 里那句"Preview images do not update latest or prerelease channels"说的就是它。变体后缀另算:musl 不带后缀,glibc 带-glibc,要留意这是 RustFS 项目自己的约定,不是 Docker 的通用规范,别的镜像怎么区分得看各自文档。三个注册表的 tag 命名完全一致,换地址时不用记两套版本号。
RustFS 官方文档的 Docker 安装页给出的拉取命令同样是docker pull rustfs/rustfs:latest,文档里的 docker-compose.yml 示例用的也是这个路径。想从源头核对的话,GitHub 仓库 README 的运行示例、官方文档容器章节、三个注册表的包页面,信息是互相咬合的。三个注册表上的镜像都是公开仓库,匿名即可拉取;quay.io 的仓库页上,Docker 和 Podman 两种拉取命令都直接列了出来。
换个地址,命令只改一处
被限流最直接的解法就是换 pull 地址。把 CI 脚本里的rustfs/rustfs:latest改成ghcr.io/rustfs/rustfs:latest,或者quay.io/rustfs/rustfs:latest,事情就结束了。Docker Hub 的限流政策只约束它自己,GHCR 和 quay.io 各有各的配额体系,跟那扇门无关。
唯一需要动脑子的地方是写法习惯。Docker Hub 允许省略 registry 前缀,很多人因此忘了镜像全名里其实带着 docker.io;GHCR 和 quay.io 必须写全路径。这是换源时最容易打错的地方,改完先在本地手动 pull 一次,再用 docker images 确认镜像真的到手,然后才提交,比让流水线替你试错便宜。
对已认证的付费 Docker 用户,限流本来就不构成问题。这个技巧针对的是未认证的 CI 环境,以及免费额度见底的团队。
登录也是一条路,但有代价
官方文档列了几种让拉取被正确计入配额的方式:Docker Desktop 里登录,独立安装的 Docker Engine 用 docker login,Swarm 要加 --with-registry-auth 参数,GitHub Actions 用 login action。登录之后,免费用户的窗口从 100 次提到 200 次,付费订阅不限量。
代价在凭据管理。CI 里做 docker login,意味着要在构建平台里存一个 access token,轮换、权限、泄露面都跟着来。共享出口 IP 触发的滥用限速也不会因为登录就完全消失,文档明说了这一点。对很多小团队,与其维护一套 token 生命周期,不如直接换 pull 地址,后者不改任何基础设施,也少一个需要看管的东西。顺便说一句,Personal 本身是免费计划,注册一个 Docker ID 就能拿到两百次的窗口,这一步不花钱,缺的只是流水线里那行登录命令和一枚 token。
流水线里的改法
改 CI 配置时留意三个位置:Dockerfile 里的 FROM 行、docker-compose.yml 里的 image 字段、Kubernetes manifest 里的镜像地址。官方文档里的 compose 示例写的是rustfs/rustfs:latest,如果你直接引用了这类文件,记得连镜像地址一起改。
还有一个可以养成的习惯:在 CI 脚本里做一次 registry 故障切换。先试 Docker Hub,拉取失败再切 GHCR,脚本层面十几行就能写完。比起半夜被告警报叫醒,这十行脚本的性价比高得多。
同步的边界在哪里
利好说完了,边界也得摆出来。
三源同步是同一个 workflow 做完的,但构建流水线本身需要时间,三个注册表上新 tag 的出现时间可能有几分钟级别的先后差。发版后的第一时间去 GHCR 拉某个刚发布的版本,偶尔会碰上还不存在的情况,等一会儿就好。对日常使用无关紧要,对追发版追得紧的自动化脚本,心里要有这个数。
tag 命名也要留心。musl 无后缀、glibc 带-glibc的规则在三个注册表上一致,但从 Docker Hub 切过去时,确认一下本地原来拉的是哪个变体,别想当然。
生态习惯的差异同样存在。不少公开的 Compose 模板、K8s 示例、技术文档里写死了 docker.io 地址,换源意味着这些参考材料不能照抄。GHCR 和 quay.io 在各自生态里的位置也不同,Red Hat 系工具链对 quay.io 更熟,GitHub 系工作流对 GHCR 更顺,选哪个更多看团队的生活环境,而非技术优劣。
还有一层缓存的小坑。切了 registry,CI 上原有的镜像层缓存对不上新地址,头几次构建会明显变慢,跑几轮缓存热起来就恢复正常。这不算成本,但排期紧张的时候,把这几轮慢构建算进切换计划里,心里更踏实。
限流不会消失,篮子可以多备几个
容器镜像的可移植性是个老话题,这次它换了个形式回来。一份镜像躺在三个注册表里,Docker Hub 限流就从"项目风险"降级成了"配置项"。RustFS 把同步发布做进了发版流水线,用户要做的只是记住两个备选地址。
下次流水线又在半夜撞上 toomanyrequests,先看看你依赖的镜像有没有第二扇门。有,换个地址继续跑;没有,给项目维护者提个 issue 也不亏。限流这件事短期看不到终点,把鸡蛋分进几个篮子,是每个依赖容器镜像的团队迟早要补上的一课。
说到底,这次限流只是把一个早就该做的检查提前了:你的基础设施里,还有多少环节默认了自己只有一个供应商。镜像如此,其他依赖也一样。把它当成一次顺手的检查,比等半夜告警响了再补救划算。