☰
ServerPackCreator 自托管 Runner 的镜像仓库缓存实战:终结 ghcr.io 429 突发限流
2026/10/16 2:54:52 网站建设 项目流程
  • 后端
  • 前端
  • 桌面应用
  • CLI
  • 游戏开发

【免费下载链接】ServerPackCreator

Create a server pack from a Minecraft Forge, NeoForge, Fabric, LegacyFabric or Quilt modpack!

项目地址:https://gitcode.com/gh_mirrors/se/ServerPackCreator
点击查看免费下载

本篇技术指南以 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-jammybuildkitd.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 的镜像源差异

任何缓存方案都绕不开两条硬约束,文档原文与源码共同确认了它们:

  1. Docker Engine 的registry-mirrors只镜像 Docker Hub,镜像不了任何其他 registry。官方说法是“目前不可能镜像另一个私有 registry;只有中央 Hub 可以被镜像”。把它指向一个ghcr.io的缓存,守护进程会静默忽略——对ghcr.io/anything的拉取根本不会查询镜像列表。
  2. 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、测试启动的每个容器dinddaemondind 自己的 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 的 daemonbusybox、alpine(容器集成测试、test.yml的预拉取)--registry-mirror是
BuildKit(builder 内)ghcr.io/linuxserver/baseimage-ubuntu:noble、docker.io/library/eclipse-temurin工作流里的 mirror 小节是
dind 的 daemonmcr.microsoft.com/powershell什么也没有——既不是 Hub 也不是 BuildKit否
dind 的 daemonghcr.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 卫生:免费,所以先查

  • runnerconfig.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!

项目地址:https://gitcode.com/gh_mirrors/se/ServerPackCreator
点击查看免费下载

相关推荐

上一篇:LikeC4 Vite 插件演进与原理深度解析:从虚拟模块到项目级 Web Components(v1.49.0–v1.59.3)
下一篇:Reto 24 实战解析:在 retos-programacion-2023 中实现与破解凯撒密码(Cifrado César)

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询