- 后端
- 前端
- 桌面应用
- CLI
- 游戏开发
【免费下载链接】ServerPackCreator
Create a server pack from a Minecraft Forge, NeoForge, Fabric, LegacyFabric or Quilt modpack!
本篇技术指南以 ServerPackCreator 仓库的 RUNNER-REGISTRY-CACHE.md 为骨架,完整讲解如何在运行forgejo-runner的主机上为 Docker Hub 与 ghcr.io 建立 pull-through 镜像缓存,从根因上消除 CI 构建被 ghcr.io 按主机限流(429 Too Many Requests)击穿的问题。读完你将掌握:如何测量并确诊“按主机计数的令牌桶限流”、Docker Engine 与 BuildKit 在镜像源配置上的根本差异、持久化 builder 与双registry:3拉通缓存两种落地方案,以及配套工作流如何“软失败”地探测缓存并把 BuildKit 指向上游或缓存。
问题背景:被测量的 429,而不是“运气不好”
在 ServerPackCreator 的 Forgejo CI(.forgejo/workflows)中,docker-test.yml在 57 次完成的运行里有 3 次输给了 ghcr.io 的429——2026-09-27 的 run 676、run 680,以及 2026-09-28 的 run 739。三次失败的报错都携带完全相同的两个数字:
toomanyrequests: retry-after: 933.17µs, allowed: 44000/minute文档与 .claude/rules/ci-workflows.md 中的“ghcr's 429 is a burst limiter on the host”一节共同给出了这个错误的关键解读:
allowed: 44000/minute不可能是单次构建能耗尽的配额,而retry-after只有不到一毫秒——这不是配额,而是按请求来源主机计数的令牌桶(token bucket);- run 739 还证明账户级凭据无法解除它:那次任务日志里写了
Login Succeeded,buildkit 也发出了[auth] … token for ghcr.io顶点,但 blob 的GET依然返回 429。
结论因此明确:真正的杠杆是“离开这台主机的每分钟请求数”,修法不是等待或认证,而是停止发出大多数请求。这一结论在 docker-test.yml 的注释里有完整的现场记录,包括Login Succeeded仍被 429 击穿的那次运行。
谁在拉镜像,从哪里拉
主机上的镜像拉取有四个互不共享缓存的消费者:
| 消费者 | 拉取内容 | 配置位置 |
|---|---|---|
| Runner 守护进程 | ghcr.io/catthehacker/ubuntu:runner-latest(任务容器)、service 镜像 | forgejo-runner的config.yaml、/etc/docker/daemon.json |
| 守护进程(供测试) | busybox、alpine、mcr.microsoft.com/powershell(DockerJavaContainerEngineIT、grinder 容器集成测试) | /etc/docker/daemon.json |
| BuildKit(buildx builder 容器内) | ghcr.io/linuxserver/baseimage-ubuntu:noble、docker.io/library/eclipse-temurin:21-jdk-jammy | buildkitd.toml,经setup-buildx-action传入 |
| grinder | 它自己的 server 镜像 | grinder 自己的引擎配置 |
只有第三类(BuildKit)真正失败过——也恰好是唯一可以按 registry 做镜像代理的一类。失败点可以从 docker/Dockerfile 直接印证:构建阶段FROM eclipse-temurin:21-jdk-jammy AS builder,运行时阶段FROM ghcr.io/linuxserver/baseimage-ubuntu:noble,这两条FROM正是被限流的位置。
测试侧的镜像需求同样可以溯源:容器测试用docker cp把模板拷入 busybox/alpine/PowerShell 容器并运行真实解释器(见 TemplateInterpreterRunner.kt),test.yml 和 grinder-container-it.yml 都会在测试前docker pull busybox:latest等镜像,把拉取移出测试自身的超时窗口。
决定所有方案形态的约束:Engine 与 BuildKit 的镜像源差异
任何缓存方案都绕不开两条硬约束,文档原文与源码共同确认了它们:
- Docker Engine 的
registry-mirrors只镜像 Docker Hub,镜像不了任何其他 registry。官方说法是“目前不可能镜像另一个私有 registry;只有中央 Hub 可以被镜像”。把它指向一个ghcr.io的缓存,守护进程会静默忽略——对ghcr.io/anything的拉取根本不会查询镜像列表。 - BuildKit 没有这个限制。
[registry."ghcr.io"] mirrors = […]可以工作,带路径的镜像也可以(如harbor.example/proxy.ghcr.io),而后者正是 daemon 也会拒绝的形式。
另外还有一条文档特意说明“从源码验证而非文档验证”的性质:BuildKit 的 fallback 是内置的。NewRegistryConfig(buildkitd 参考文档不提及此行为)通过先把每个 mirror 追加进主机列表、再把上游主机追加在最后来构建列表,containerd 的 resolver 按顺序遍历该列表。因此一个宕机或返回 404 的缓存只会造成一次失败的尝试,而不会造成一次失败的构建。镜像条目只携带HostCapabilityPull | HostCapabilityResolve——推送永远走上游,所以release-build.yml向 ghcr 和 Docker Hub 的推送不受任何影响(推送路径见 release-build.yml)。
Option 0——持久化 builder:最便宜、纯仓库侧、无主机服务
docker/setup-buildx-action默认每个 job 创建一个全新的 builder并在结束时删除,所以每次 docker-test 运行都要从头拉取两个基础镜像。在持久化的自托管 runner 上这是纯浪费,而 action 恰好提供了针对性的输入:
- name: Set up Docker Buildx uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 with: name: spc-builder # 固定名称:已存在时复用 keep-state: true # "only useful on persistent self-hosted runners" —— action 自己的描述 cleanup: false它能买到什么:约 160 MB 的eclipse-temurin层和 ghcr 基础镜像留在 builder 的内容存储里,杀死 run 680 与 739 的blob拉取彻底不再发生;同时从每次运行里移除约 1 分 41 秒的Set up Docker Buildx步骤。
它买不到什么:buildkit 每次构建仍会把:noble和:21-jdk-jammy解析成 digest——每个FROM一次 registry 请求——而一次 manifest 解析正是杀死 run676的原因。所以它缩小暴露面,但没有关闭它。
接受前需要同意的代价:
- 磁盘无上限。永远不会被删除的 builder 无人清理。要么配一个定时器跑
docker buildx prune --builder spc-builder --keep-storage 20GB -f,要么在buildkitd-config-inline里加[worker.oci] gc = true块。 - 卡死的 builder 会一直存在。恢复手段是在主机上
docker buildx rm spc-builder。 - 两个 job 共享它。
concurrency按 ref 分组,所以一次 push 到develop会在并行中跑两次同一提交;buildkit 能安全地串行化并发 solve,且现在它们共享缓存而不是重复拉取。这是收益,但属于“需要一次真实运行来确认、而不是靠推理确认”的那类说法。
Option 1——主机上的 pull-through 缓存(推荐方案)
CNCF Distribution(registry:3)的代理模式是一个实例代理一个上游(官方:“currently possible to mirror only one upstream registry at a time”),两个上游就意味两个容器——它们很小,完全没问题。
0. 拓扑:先看清数据面,因为下面一切都由它决定
Job 不跟主机的 Docker daemon 说话。runner 的docker_host是unix:///sockets/docker.sock,而那个 socket 来自一个docker:dindservice 通过共享卷提供的 socket——它看起来像主机 socket 挂载,实际不是:
docker-in-docker: image: docker:dind container_name: 'docker_dind' privileged: 'true' volumes: [ dind-storage:/var/lib/docker, dind-socket:/sockets ] command: ['dockerd', '-H', 'unix:///sockets/docker.sock', '--tls=false', '-G', '1001', ...]| 容器 | 由谁创建 | 能看到什么 |
|---|---|---|
docker_dind、runner、缓存 | 主机daemon | 主机的网络 |
| 每个 job 容器、每个 buildx builder、测试启动的每个容器 | dinddaemon | dind 自己的 bridge |
所以从 job 里执行docker network inspect runners_default问的是 dind——它从未听说过这个网络。service 名无法跨过这个边界;路由可以。两侧都在 2026-10-02 用这个拓扑的忠实复刻(用户自定义网络上的docker:dind旁边放一个registry:3)测量过:
from a container created by the dind daemon: wget http://registry-cache-ghcr:5000/v2/ -> bad address (DNS: no) wget http://172.31.77.6:5000/v2/ -> 200 {} (routing: yes)这就是“用固定 IP寻址缓存”的全部论据。注意这不只是本仓库的写法偏好:docker-test.yml与release-build.yml的“Point BuildKit at the host's registry caches”步骤里把缓存地址写成环境变量GHCR_CACHE: "172.31.77.6:5000"/DOCKERHUB_CACHE: "172.31.77.5:5000",并明确注释“如果它们与主机漂移,什么也不会坏:探测找不到任何东西,构建走上游”——软失败是刻意设计。
1. 缓存:独立网络 + 固定地址
先选一个主机上没有任何其他东西使用的子网——冲突会造成让人困惑的故障:
docker network inspect $(docker network ls -q) \ --format '{{.Name}} {{range .IPAM.Config}}{{.Subnet}}{{end}}'然后把它加进runner 自己的 compose 文件,让缓存与 dind 一起起来:
networks: registry-cache: name: registry-cache ipam: config: - subnet: 172.31.77.0/24 volumes: ghcr-data: dockerhub-data: services: docker-in-docker: # ... 已有内容不变,另加: networks: - default # LANDMINE: 只要显式命名任何一个网络,隐式的 `default` 就会被丢弃, - registry-cache # 而把 dind 从 `runners_default` 上拿掉 = runner 再也够不到它的 daemon。 command: ['dockerd', '-H', 'unix:///sockets/docker.sock', '--tls=false', '-G', '1001', '--registry-mirror', 'http://172.31.77.5:5000', '--dns', '127.0.0.1', '--dns', '185.12.64.2', '--dns', '185.12.64.1'] registry-cache-dockerhub: image: registry:3 container_name: registry-cache-dockerhub restart: unless-stopped environment: REGISTRY_PROXY_REMOTEURL: "https://registry-1.docker.io" REGISTRY_PROXY_TTL: "168h" # 可选:认证上游把 Docker Hub 匿名的 100 次拉取/6 小时变成账号额度。 # 缓存随后会提供该账号能看到的一切,所以让它留在这个网络上、不要发布端口。 # REGISTRY_PROXY_USERNAME: "…" # REGISTRY_PROXY_PASSWORD: "…" volumes: [ dockerhub-data:/var/lib/registry ] networks: registry-cache: ipv4_address: 172.31.77.5 registry-cache-ghcr: image: registry:3 container_name: registry-cache-ghcr restart: unless-stopped environment: REGISTRY_PROXY_REMOTEURL: "https://ghcr.io" REGISTRY_PROXY_TTL: "168h" volumes: [ ghcr-data:/var/lib/registry ] networks: registry-cache: ipv4_address: 172.31.77.6不发布任何端口。没人需要它们,开放的代理缓存也不该发布。这还顺带绕开了本仓库CLAUDE.md里记录过的INPUT policy DROP陷阱(那次 131 秒故障被用三种错误方式诊断过,见 CLAUDE.md 的“A symptom shared by two layers”条目):没有发布到主机的端口可拨,主机的 filter 表根本不进入画面。
docker compose up -d会重建docker_dind,因为它的 networks 和 command 都变了。这会杀掉所有进行中的 job——请在 runner 空闲时操作。dind-storage是命名卷,所以 dind 的镜像缓存能跨重启存活。
2. 把 dind 挂到缓存网络是承重墙——移除它验证过
没有这一步,job 的报文会经 dind 的runners_default网关离开,然后被 Docker 的DOCKER-ISOLATION-STAGE链在网桥之间丢弃。失败形态是超时而不是拒绝——恰好是那种会被读成“缓存不工作”的形态:
dind attached to the cache network: wget http://172.31.77.5:5000/v2/ -> 200 dind detached (nothing else changed): wget http://172.31.77.5:5000/v2/ -> download timed out所以缓存一旦“看起来死了”,先docker network inspect registry-cache确认docker_dind还在里面,再怀疑 registry 本身。
3. 覆盖了什么,没覆盖什么
dind 的dockerd --registry-mirror只对 Docker Hub 生效——这不是疏漏,是 Docker Engine 的限制(“目前不可能镜像另一个私有 registry”)。BuildKit 没有此限制,所以 ghcr 在那边处理。
| 拉取者 | 拉什么 | 被谁缓存 | 覆盖 |
|---|---|---|---|
| dind 的 daemon | busybox、alpine(容器集成测试、test.yml的预拉取) | --registry-mirror | 是 |
| BuildKit(builder 内) | ghcr.io/linuxserver/baseimage-ubuntu:noble、docker.io/library/eclipse-temurin | 工作流里的 mirror 小节 | 是 |
| dind 的 daemon | mcr.microsoft.com/powershell | 什么也没有——既不是 Hub 也不是 BuildKit | 否 |
| dind 的 daemon | ghcr.io/catthehacker/ubuntu:runner-latest(job 容器) | 什么也没有——daemon 层没有 ghcr | 否 |
两行未覆盖需要 Option 3 的 MITM 代理,且只有当测量表明它们占真实流量份额时才值得。真正失败的那一行——BuildKit 的 ghcr 拉取,三次运行输给 429——被覆盖了。
3a. 工作流那一半:已经在位
docker-test.yml 和 release-build.yml 各自用一个步骤探测每个缓存(GET /v2/),只为应答者生成buildkitd.toml小节,再作为buildkitd-config传给setup-buildx-action。有三件事是刻意为之:
driver-opts: network=…被移除了。有了固定 IP,builder 没有要加入的网络;移除它同时也移除了该步骤能直接杀死构建的唯一方式(network runners_default not found,正是弄坏 run 797 的那条错误)。- 探测问的是真正要紧的问题。“这个网络存在吗”原本是“builder 能到达缓存吗”的代理,却答错了 daemon。job 里的一次
GET /v2/与 builder 走的是同一条路径——两者都是同一个 dind daemon 的子进程。 - 按 registry 软失败。不应答的缓存被剔除;该 registry 走上游,另一个走缓存。用停掉其中一个的方式实测过。
每个 registry 的第二段小节([registry."<host:port>"] http = true)不是可选项,也不显而易见:newMirrorRegistryHost默认给 mirror 用https,而fillInsecureOpts会在顶层同名 section 下查找 mirror 自身的 scheme。没有它,buildkit 会去拨https、握手失败、回退上游——然后静默地什么都不做。
Fallback 同样是从源码验证而非文档验证(文档不陈述它):NewRegistryConfig把每个 mirror 追加在前、上游主机追加在最后,containerd 的 resolver 顺序遍历;mirror 条目只带HostCapabilityPull | HostCapabilityResolve,推送永远上游——release-build.yml向 ghcr 与 Docker Hub 的推送完全不受影响。
4. 证明它生效了,再把数字写下来
首先证明每个容器是“缓存”而不是空 registry。REGISTRY_<SECTION>_<KEY>是 Distribution 文档化的config.yml环境变量覆盖方式,而拼错的覆盖会被静默忽略:registry 会以空可写registry 而非代理的身份启动,然后 404 一切拉取——看起来和网络故障一模一样。下面第二行的200只可能来自“经代理从上游取回”,所以它本身就是全部测试:
curl -s -o /dev/null -w '%{http_code}\n' http://172.31.77.6:5000/v2/ # 200 curl -s -o /dev/null -w '%{http_code}\n' \ -H 'Accept: application/vnd.oci.image.index.v1+json' \ http://172.31.77.6:5000/v2/linuxserver/baseimage-ubuntu/manifests/noble # 200 docker logs registry-cache-ghcr | grep -c 'GET /v2/' # non-zero这些要在主机上跑(主机在registry-cache桥上)。从 job 里跑,就是工作流已经执行过的那个探测。
然后证明 dind 确实在用 Hub 镜像——这是另一个独立的声明:
docker -H unix:///var/lib/docker/volumes/.../docker.sock info | grep -A2 'Registry Mirrors' # 或者更简单,job 跑过之后: docker logs --since 10m registry-cache-dockerhub | grep -c 'GET /v2/'按本仓库“构建与 CI 变更用测量而非测试验证,且数字写进 commit message”的约定(见 CLAUDE.md 的 “Build logic is verified by measurement” 一节),把前后值放进提交信息:
docker exec registry-cache-ghcr du -sh /var/lib/registry # 首次构建增长,随后趋于平稳 docker logs --since 10m registry-cache-ghcr | grep -c 'GET /v2/'真正关键的数字是同一提交第二次运行时Build image步骤的耗时:如果镜像在服务,FROM阶段会降到近乎本地磁盘的速度,且没有任何 ghcr blobGET离开主机。
Option 2——一个端点代理多个上游(Harbor)
Harbor 的 proxy-cache 项目用一个服务前置多个上游,消除了“每个 registry 一个容器”的蔓延。BuildKit 可以直接用它,因为它接受带路径的 mirror(mirrors = ["harbor.example/proxy.ghcr.io"])。Docker Engine 不行——daemon 的 mirror URL 必须是域名根、不带路径——所以 Harbor 能替换两个registry:3容器和工作流里的 mirror 小节,但替换不了 dind 的--registry-mirror(它仍然需要一个无路径的根)。如果这台主机将来要为不止本仓库做缓存,它是正确的答案;只为两个基础镜像,它就是一堆过于庞大的机器。
Option 3——连 daemon 一起透明缓存(MITM 代理)
唯一能为dockerd缓存ghcr.io的办法是停止让 dockerd 来做:跑一个缓存代理(常用rpardini/docker-registry-proxy),通过 systemd drop-in 给 daemon 设HTTPS_PROXY。它能缓存包括 runner 镜像在内的每个 registry,并需要 daemon 信任它的 CA。这正是能关闭第 3 节两行未覆盖项的方案——mcr.microsoft.com/powershell和ghcr.io/catthehacker/ubuntu:runner-latestjob 镜像。但只有当测量表明它们占真实流量份额时才值得;默认它们不是,因为container.force_pull是false,且 dind 的dind-storage卷让 runner 镜像跨重启存活。建任何东西之前,先检查下面这个设置。
Runner 卫生:免费,所以先查
runner
config.yaml里的container.force_pull。它默认false,但大多数forgejo-runner的 docker-compose 示例把它设成true,而每次 push 要跑好几个 job。如果这里是true,主机在每个 job拉一次ghcr.io/catthehacker/ubuntu:runner-latest却毫无收益——这可能单独就是突发源。建任何东西之前先查它。一次 push 曾把同一提交构建两次,让下面每次拉取翻倍——2026-10-02 已修复。三个条件必须同时成立,且在这台主机上全部成立:工作流同时以
push:和pull_request:触发;一个长命的 PR 的 head 正是被 push 的分支(长期存在的develop→beta);concurrency group 携带${{ github.ref }},它在refs/heads/develop与 PR 的 ref 之间不同,所以没有任何去重。按 ref 的键是使能条件而非原因——正是它让第二次运行没有被排到第一次后面。test.yml、docker-test.yml、grinder-container-it.yml是触发两次的三个,pull_request:已从全部三个中移除:本实例上的每个 PR 都来自本仓库的分支,push:本来就会构建它们。docs.yml、qodana.yml和 release 工作流只以push:触发,从不翻倍。运行列表里旧行为的最清晰证据:索引665(#678)与666(develop),同一提交,都在2026-09-27T15:10:11启动;以及2026-10-01T17:03:00排队的那批九个运行——正是它让DockerJavaContainerEngineIT在 2026-09-27 连续三次运行失败,并在 2026-10-01 打倒了test.yml和grinder-container-it.yml。代价写在各工作流自己的头部注释里:merge 结果不再被单独构建,fork PR 也得不到 Forgejo CI(GitHub 镜像的test.yml保留了pull_request:来覆盖这个场景)。
这些方案解决不了什么
缓存减少请求,但不会让主机免疫。任何新 tag的首次拉取仍然走上游、仍然可能撞上限流器——这正是两个工作流里continue-on-error加重试保留下来、与这里做的一切无关的原因。docker-test.yml的“Build image (retry)”步骤先sleep 30再重试一次,因为限流器亚毫秒级就复位,一分钟还在限流就值得大声失败而不是硬磨。在release-build.yml里同样的重试更贵:no-cache: true意味着第二次尝试要重建整个 Gradle 阶段(约十五分钟),而重复推送相同 tag 是幂等的——这是保险的价格。
仓库内的配套证据清单
想深入源码级验证,可按以下路径继续阅读当前仓库:
- 工作流探测与软失败实现:docker-test.yml、release-build.yml(两处各有一个 “Point BuildKit at the host's registry caches when they answer” 步骤,完整包含
add_mirror函数与buildkitd.toml生成逻辑); - 限流根因与重试语义的 CI 规则记录:.claude/rules/ci-workflows.md(“ghcr's 429 is a burst limiter on the host”一节);
- 被拉取的两个基础镜像:docker/Dockerfile(
eclipse-temurin:21-jdk-jammy与ghcr.io/linuxserver/baseimage-ubuntu:noble); - 容器测试镜像的消费方:test.yml 的预拉取步骤、TemplateInterpreterRunner.kt(busybox/alpine/PowerShell 容器内模板语法校验);
- 主机防火墙
INPUT policy DROP陷阱的完整复盘:CLAUDE.md(“A symptom shared by two layers”条目); - 缓存方案的运维对照文档:RUNNER-REGISTRY-CACHE.md 本身。
- 后端
- 前端
- 桌面应用
- CLI
- 游戏开发
【免费下载链接】ServerPackCreator
Create a server pack from a Minecraft Forge, NeoForge, Fabric, LegacyFabric or Quilt modpack!
相关推荐
突破性能瓶颈:Sealos镜像仓库并发拉取与缓存优化实战
突破性能瓶颈:Sealos镜像仓库并发拉取与缓存优化实战 你还在为镜像拉取慢拖累部署效率?当Kubernetes集群同时拉起50+应用实例时,是否频繁遭遇镜像仓
云原生后端前端容器编排微服务突破GitLab Runner构建瓶颈:Skopeo容器镜像缓存实战指南
突破GitLab Runner构建瓶颈:Skopeo容器镜像缓存实战指南 在现代CI/CD流水线中,GitLab Runner的构建效率直接影响开发迭代速度,而
云原生CLI镜像仓库CacheTool性能优化:如何快速监控和分析OPcache状态
CacheTool性能优化:如何快速监控和分析OPcache状态 CacheTool是一款强大的CLI应用和库,专门用于管理APC和OPcache,帮助开发者轻
运维开发工具
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考