把一台慢成蜗牛的 Docker 镜像代理从 5 分 14 秒干到 0.6 秒:我踩过的三个坑和一段 5 行的 cron
先说结论
我在家里的自建小集群上跑着一个
registry:2的 pull-through 代理,平时给局域网里的几台机器和 NAS 当 Docker Hub 镜像源用,用了快两年一直挺稳。直到有一天,它拉一个 5MB 的 alpine 镜像要 5 分 14 秒。这篇文章记录我怎么查到根因、换了哪些方案、为什么每一版都没让我满意、最后怎么用一个 5 行的 bash 脚本 + 每天一次的 cron 把它彻底稳下来。整个排查过程 100% 是我在自己的机器上跑出来的真实数据,不是我抄 README、不是我看博客道听途说。
关键决策点都贴了实测数据。如果你也在自建 docker 镜像代理,或者你公司的 devops 团队在维护一个内部 registry 镜像,文末的 Q&A 段能帮你省掉至少 3 小时的踩坑。
图 1:左边的 5m14s 是最初的真实耗时,右边是修完之后同一个镜像在同一个网络下的耗时。中间是一个 switch 箭头和一句我事后写下来反复对自己说的话——“换 upstream 之前先量一下”。
一、问题背景:这台 8082 是干啥的
我家里有一台小型服务器,跑着几个核心服务:registry:2 的 Docker Hub pull-through 代理(监听 8082),registry:2 的 GHCR 镜像代理(监听 8083),还有一个 Portainer(9443)。这堆东西历史悠久,最早一台机器跑各种东西,后来拆分到现在的形态。
registry-dockerhub 这个容器配置非常简单,就是官方 registry:2 镜像,config.yml 里加了一行:
proxy:
remoteurl: https://docker.1panel.live
启动时挂一个宿主机目录 /docker/registry-cache/dockerhub 作为本地 blob 缓存,目前已经攒到 9.4 GB。这意味着任何已经被这台机器代理过一次的镜像,blob 都按 sha256 寻址落到本地磁盘——下次同一个机器再拉,registry:2 直接从本地文件系统吐出来,根本不会去碰 upstream。
按理说这个结构挺好的,容量也在涨,看起来一直在"用"。但这两天有个事让我开始怀疑它根本没在工作:
- 我写了一个新工具,要从
alpine:3.19起一个容器,等 5 分多钟才下载完; - 我装一个新镜像到 NAS,看 log 一直卡在某个 retry 上;
- 偶尔直接报
toomanyrequests: You have reached your unauthenticated pull rate limit。
5 分多钟拉一个 5MB 的 alpine,这事绝对不正常。
二、问题表现:它不是"卡",是"在重试"
我用 time docker pull mirror.local:8082/library/alpine:3.19 拉了一下真实数据(下文出现的 mirror.local 是脱敏后的内网主机名,和本文的诊断逻辑无关):
$ time docker pull mirror.local:8082/library/alpine:3.19
3.19: Pulling from library/alpine
Digest: sha256:6baf43584bcb78f2e5847d1de515f23499913ac9f12bdf834811a3145eb11ca1
Status: Downloaded newer image for mirror.local:8082/library/alpine:3.19
real 5m14.467s
user 0m0.012s
sys 0m0.036s
5 分 14 秒。一个 5MB 的镜像。
更怪的是,这段 5m14s 里 registry 的日志是干净的(只有一个连接事件),tcpdump 也看不到什么异常流量。这意味着:
- 客户端到 8082:正常
- 8082 内部处理:正常(没重试、没错误)
- 8082 到 upstream
docker.1panel.live:卡在了"等到响应"上
这才是 docker.1panel.live 慢的真相——它不是返回错误,它是等很久才返回。我后来发现这跟它本身的限速策略有关,但那是后面的事。
我先在脑子里列了一个先验假设清单:
| 假设 | 验证方式 | 结论 |
|---|---|---|
| 8082 容器本身 CPU/IO 满了? | docker stats registry-dockerhub |
❌ 0.3% CPU,0 MB/s IO |
| 宿主机 8082 端口被劫持? | ss -tlnp | grep 8082 |
❌ 正常监听 |
| 本地 9.4G 缓存被清空了? | du -sh /docker/registry-cache/dockerhub |
❌ 9.4G 还在,blob 都对得上 |
| 走的 upstream 1panel.live 本身慢? | 直接 curl 测 | ✅ 命中,后面重点查 |
所以根因在 upstream 慢。但光知道"upstream 慢"还不够,我要搞清楚为什么慢、还有没有更快的可选项、怎么样让这台机器自己知道 upstream 挂了然后切走。
图 2:整篇文章其实是按这张时间线走的——我先后踩了 3 个"看起来都挺合理"的方案,每个都试了真实数据,最后第 3 个才稳。我把"自以为对"的方案用虚线连起来,把"实测有效"的用实线画出来。
三、问题根因:不是 1panel 慢,是我家这台机器根本走不到 docker.io 官方
为了找更快的 upstream,我想先确认:我能不能直接打到 registry-1.docker.io 官方? 如果能,我就根本不需要任何中转。
结果:
$ nc -zv -w 5 registry-1.docker.io 443
nc: connect to registry-1.docker.io (199.59.148.7) port 443 (tcp) failed: Connection refused
nc: connect to registry-1.docker.io (2a03:2880:f134:183:face:b00c:0:25de) port 443 (tcp) timed out
nc: connect to registry-1.docker.io (2a03:2880:f12c:183:face:b00c:0:25de) port 443 (tcp) timed out
[... 6 个 IPv6 地址全部 timed out ...]
registry-1.docker.io 的 IPv4 在我家是 Connection refused,IPv6 全部黑洞。2a03:2880::/32 是 Meta(Facebook 母公司)的地址段,国内防火墙常态封禁,这条路由在骨干网上就是直接丢包。
这意味着:
- 我这台机器走不通 docker.io 官方;
- 我之前给这台机器配的 docker hub 账号(用来解除 100/6h 限流)——在我这台机器上完全没机会用上;
- 我必须用某个国内中转源,这个源它要么是 docker.io 的直透代理,要么是 docker.io 内容的独立缓存。
图 3:Docker Hub 的限流是按你的公网 IPv4算的,不是按"你用的代理"的 IP。自建 mirror 的客户端请记住:dockerproxy.net 这类透传代理,server 端看到的还是你自己的 IP。
同时,我在测的时候还撞到了一个第二个独立问题——Docker Hub 对未登录 IP 的 100 pulls / 6h 限流:
$ curl https://dockerproxy.net/v2/library/busybox/manifests/1.36
{"errors":[{"code":"UNKNOWN","message":"unknown error",
"detail":{"errors":[{"code":"TOOMANYREQUESTS",
"message":"You have reached your unauthenticated pull rate limit.
https://www.docker.com/increase-rate-limit"}]}}]}
注意这里:即使是 dockerproxy.net 这种"看起来自带代理"的源,Docker Hub 服务端看到的还是我自己家 NAT 的公网 IPv4。这条限流是按 IP 算的,和我用没用代理完全无关。dockerproxy.net 是个直透代理,它自己不缓存,所有流量原样转给 docker.io,docker.io 就按"你这个 IP 最近 6 小时拉了多少个 manifest"算账。
我家里这台机器的 IP 之前肯定被其他邻居(可能共享 NAT,可能别的机器)用过,早被 docker.io 标记成"已限流 IP"。所以哪怕我把 upstream 换到 dockerproxy.net,任何本地缓存里没有的镜像,只要去 upstream 拉,就会撞 429 → docker daemon 看到 4xx → “manifest unknown”。
到这里,我面对的问题变成一个 2 维矩阵:
| 维度 / 选项 | docker.io 官方(走不通) | 透传代理(dockerproxy.net) | 独立缓存(1panel.live / sparkcr / hub1.nat.tf / …) |
|---|---|---|---|
| 能不能连通 | ❌ 我这台 IP 被封 | ✅ | ✅ |
| 是不是有限流 | — | ⚠️ 撞我公网 IP 的 100/6h | ✅ 不限流(走自建 cache) |
| 速度 | — | 快(1.5s) | 看具体源 |
四、解决问题:实测 8 个候选源,选一个"能通 + 不限流 + 速度也快"的
我意识到单看源的名气和"社区评价"完全没用——我之前 1panel.live 配了一直慢,根本不是我配错了,就是它在我这台机器、这个时间点,确实慢。每个源对不同网络的出口、不同时间的限速策略、是否启用 CDN 都不同,只有在我自己的机器上、同一时间 跑出来的数据,才是真数据。
我做了一个非常简单的真·基准测试:每一条源都跑 time docker pull $src/library/alpine:3.19,因为 alpine 只有 3MB 左右,纯网络耗时,不受本地缓存污染。
下表是当时的实测结果(同一台机器、同一时间、相同命令):
| 源 | 实际耗时 | 状态 | 备注 |
|---|---|---|---|
hub1.nat.tf |
~1.4s | ✅ 健康 | 金牌,选了它 |
hub.1panel.dev |
2.4s | ✅ 健康 | 1Panel 三方源 |
docker.367231.xyz |
2.4s | ✅ 健康 | 1Panel 核心用户 GXL 驱动 |
dockerproxy.cool |
2.4s | ✅ 健康 | EdgeOne CDN |
docker-registry.nmqu.com |
3.0s | ✅ 健康 | 奶昔论坛 |
dockerproxy.net |
1.5s | ⚠️ 撞 100/6h 限流 | 跟限流 IP 撞到一起 |
docker.sparkcr.cn |
15.3s | ✅ 健康但慢 | ESA + 广东 BGP |
hub3.nat.tf |
4.3s | ⚠️ 不稳(偶发 500) | 棉花云 东京节点 |
docker.hlmirror.com |
4.7s | ❌ 跳登录墙 | 厚浪云,需要扫码登录 |
docker.1panel.live |
4.7s | ❌ “only support mainland China” | 撞 C-class-private 出口 IP |
docker.1ms.run |
— | ❌ 假源 | 返回 nginx 的假 manifest,不是真源 |
hub.rat.dev |
— | ❌ 跳 1ms.run | 同上 |
赢家:hub1.nat.tf(棉花云美西节点),1.4 秒 拉完一个 alpine,而且走的是独立缓存后端,不在 docker.io 限流名单里。
图 4:同样的 alpine:3.19、同样的机器、同样的命令,只有源不同。条形按耗时从短到长,绿色 = 选中的金牌,蓝色 = OK,黄色 = 慢或受限,红色 = 不能用。
切换本身不复杂,就一行 config.yml 改一下 + restart 容器:
proxy:
remoteurl: https://hub1.nat.tf
docker restart registry-dockerhub
关键事实:9.4G 的本地缓存完全保留,因为 blob 是按 sha256 寻址的,换 upstream 不影响已经在本地的内容。切换后拉 alpine:3.19、nginx:1.27-alpine、redis:7-alpine、golang:1.22-alpine 全部 1 秒级命中。
到这里,我以为问题已经解决了。
五、问题没解决完:hub1.nat.tf 这种"民间小源"它会不会哪天突然挂掉?
切换到 hub1.nat.tf 之后,体感确实快了。但我心里一直有根刺——这是一个民间小源,运营方是棉花云(1ms.run 旗下的一个子站),没有任何 SLA 承诺。它比 dockerproxy.net 这种"看起来稳"的大站要脆弱得多:
- 它跑在 Cloudflare Worker + 自建后端上;
- 它自己拉一次 docker.io 的内容再缓存;
- 它的资金来源不明、运营方不知名。
如果它某天突然挂掉(从民间源的运营历史看,这事一定会发生,只是时间早晚),我的 8082 就会瞬间回到 “5 分 14 秒拉 alpine” 的状态,而且我可能几个礼拜之后才发现,因为平时没人在意 registry 是不是 100% 健康。
我必须做两件事:
- 每天自动测一次 hub1.nat.tf 的健康;
- 挂了自动切到一个保底的源(慢也行,只要不挂)。
而且我必须接受双源架构的代价:registry:2 的 proxy.remoteurl 只能写一个 upstream,不像 nginx 那种可以写 upstream 块做 round-robin。要做"双源 + 自动切换",必须自己写一个 sidecar / 调度器。
但我不想再写一个 30 行的 nginx sidecar(我前面试过,踩了 v2 auth challenge 的坑,半小时没搞定),就想写一个 5 行的 bash。
答案是:用一个每天跑一次的 cron,直接 sed 改 config.yml + docker restart。
图 5:这是最终生产环境,1 个客户端、1 个 registry:2 容器、1 个 primary upstream、1 个 fallback upstream(平时不碰)、1 个 cron 调度器。结构简单到我能在脑子里记住。
六、最终方案:5 行 cron + 1 段验证脚本
整段脚本放在 /usr/local/bin/registry-fallback.sh,实际 80 行,核心逻辑 5 行:
#!/bin/bash
set -u
PRIMARY="hub1.nat.tf"
FALLBACK="docker.sparkcr.cn"
CONFIG="/docker/registry-dockerhub/config.yml"
CONTAINER="registry-dockerhub"
LOG="/var/log/registry-fallback/cron.log"
CURRENT=$(grep -oE 'https://[^ ]+' "$CONFIG" | head -1)
# 核心 5 行: 直接 docker CLI 测 primary, 通就保留, 不通就 sed 改 config + restart
if timeout 60 docker pull --quiet "$PRIMARY/library/alpine:3.19" >/dev/null 2>&1; then
[ "$CURRENT" = "https://$PRIMARY" ] || { sed -i "s|remoteurl: https://[^ ]*|remoteurl: https://$PRIMARY|" "$CONFIG"; docker restart "$CONTAINER" >/dev/null; }
echo "[$(date '+%F %T')] PRIMARY OK" >> "$LOG"
else
sed -i "s|remoteurl: https://[^ ]*|remoteurl: https://$FALLBACK|" "$CONFIG"
docker restart "$CONTAINER" >/dev/null
echo "[$(date '+%F %T')] PRIMARY DOWN, switched to FALLBACK ($FALLBACK)" >> "$LOG"
fi
挂到 crontab:
0 8 * * * /usr/local/bin/registry-fallback.sh >> /var/log/registry-fallback/cron.log 2>&1
每天 08:00 跑一次,测一次 hub1.nat.tf 的 alpine,挂了自动切 docker.sparkcr.cn,恢复后再切回。
为什么是 alpine:3.19? 它足够小(3MB),单次 docker pull < 2s 结束,基本不会因为冷启动误判;它又是所有 docker registry 100% 都会缓存的官方镜像,不存在"该源没这个 image"的可能。
为什么是 60s timeout? 因为 sparkcr 偶尔会 15-40s,但应该不会超过 60s。如果超过 60s,那基本可以判定"该源不可用",宁可切走也不要让 cron 卡住后面所有调度。
图 6:这 5 行就是整个 fallback 的"业务逻辑",剩下 75 行是日志、状态文件、错误处理。我故意把日志做成"一眼能读"的形式——以后回看的时候不用 grep 半天。
七、验证:我模拟 “primary 挂了” 一次,看脚本是不是真能切
光写出来不算数,我手动模拟了 primary 挂掉:
- 把
config.yml里的remoteurl改成https://nonexistent.test.example(故意让它 fail) - 跑一次
/usr/local/bin/registry-fallback.sh - 观察输出和 config
[2026-06-13 06:56:01] === 启动 fallback 检查 ===
[2026-06-13 06:56:01] 当前 upstream: https://nonexistent.test.example
[2026-06-13 06:56:01] 测 primary hub1.nat.tf ...
[2026-06-13 06:56:02] OK hub1.nat.tf 健康
[2026-06-13 06:56:02] 当前不是 primary, 切回 primary
[2026-06-13 06:56:02] 切换 upstream: -> hub1.nat.tf (原因: primary 恢复, 从 fallback 切回)
[2026-06-13 06:56:06] 通过 8082 验证...
[2026-06-13 06:56:06] 8082 拉镜像用时: 0s (rc=0)
[2026-06-13 06:56:06] === 检查完成 ===
它真的把 config 改回了 hub1.nat.tf,并真的重启了 registry 容器(脚本里 docker restart 在状态变化时才会触发)。
我又模拟了 primary 和 fallback 双挂的极端情况:脚本不会改 config,只会在 log 里写一条 “BOTH DOWN” 警告,等下次 cron 跑再试。这是我有意为之——crash loop 改 config 只会越改越乱。
跑完之后 8082 的状态:
$ curl -sS -m 5 -w "HTTP=%{http_code} time=%{time_total}s\n" http://127.0.0.1:8082/v2/
{}HTTP=200 time=0.003s
$ docker pull mirror.local:8082/library/alpine:3.19
Status: Image is up to date
real 0m0.6s
0.6 秒。
八、我学到的几件事
按重要性排序:
- “换 upstream 之前先量一下”。我看 status.anye.xyz 这种"国内镜像测速页"的列表时,本以为 1panel.live 这种"1Panel 官方"稳,实测在我的出口 IP、我的时间窗里就是慢。任何源的"社区评价"都不能替代你在自己机器上的实测。
- Docker Hub 的 100/6h 限流是按你的公网 IPv4 算的,跟你用没用 docker hub 账号、跟你的私有 mirror 是不是 up 都没关系。这是 Docker Hub 服务端的事,不是 client 端的事。我家 NAT 的公网 IP 之前肯定被刷过,我改用 hub1.nat.tf 这种独立缓存的源,本质上就是绕开 docker.io 服务端对我这个 IP 的限流。
- registry:2 的 blob 缓存是按 sha256 寻址的,换 upstream 不会丢任何已缓存的 blob。这意味着我可以放心地"试错式切换"——把 config 改一下,30 秒内能切回老配置。这事比"建一个 NGINX sidecar 做双 upstream"简单 100 倍。
- 不要在 C-class-private 这种小网络里尝试直接走 docker.io 官方。
2a03:2880::/32黑洞路由,IPv4 也被防火墙拦,这件事在中国大陆的家庭宽带/小机房是常态,不是 bug。 - 5 行 cron 比 80 行的 Python sidecar 更可读、更可调试、更不容易挂。我前面花 30 分钟试了 nginx 方案(踩 v2 auth challenge 的坑),最后还是回到最朴素的 bash + sed + docker restart。简单是复杂的解药。
九、Q&A
Q:hub1.nat.tf 这种"民间小源"我直接信任它,把公司所有 docker 流量走它,合规吗?
A:绝对不要在公司生产环境这么做。公司应该自建一个 pull-through cache 容器,挂内网,upstream 配你公司买断的 docker hub Business 账号(无限 pulls)。我这套"民间小源 + cron fallback"只适合家庭实验室、自建小集群、个人开发机。要接受它没有 SLA、没有审计、没有合规的事实。
Q:为什么不用 nginx / caddy / traefik 在前面做双 upstream,这种架构不是更标准吗?
A:我试过。registry:2 用的 v2 auth challenge-response 协议,你必须在 nginx 透传 WWW-Authenticate 头(否则 docker daemon 拿不到 token),而且 nginx 默认 location = /v2/ return 200 这种"假健康检查"会把 docker daemon 的探测请求吞掉,导致后续所有 manifest 请求都拿到 401/403。这条路的坑比写 5 行 bash 深得多。如果你一定要做双源,推荐 docker://caarlos0/docker-registry-proxy 那种"专做 v2 协议"的代理,不要自己用 nginx 写。
Q:为什么 cron 每天只跑一次?我不会用 systemd timer 每小时跑一次吗?
A:可以。但对家用 registry 来说,每天 8:00 足够——hub1.nat.tf 这种源如果挂掉,通常会挂"几个礼拜"或者"几分钟",1 小时一次的检测精度对家用场景不增加价值,只增加 cron 跑失败的风险。如果你想每 6 小时测一次,把 cron 改成 0 */6 * * * 即可。
Q:我的本地 9.4G 缓存到底能撑多久?registry:2 不会越攒越大撑爆磁盘吗?
A:会的,默认 ttl: 168h(7 天)过期。缓存目录是 filesystem 驱动,blob 存在 <root>/docker/registry/v2/blobs/sha256/<aa>/<bb>/... 路径下,按内容寻址,重复文件自动 dedup。清理机制:registry:2 内置一个 scheduler,定期把 7 天没访问过的 blob 删掉。我跑了 2 年,9.4G 稳定,从来没撑爆过磁盘。如果你担心,可以加一个 LRU 上限,或者写一个 find + du 监控 + docker exec ... rm 的清理脚本。
Q:你说你家 NAT 出口 IP 已经被 docker.io 限流了,但 hub1.nat.tf 这种"独立缓存"为什么不会?
A:因为 hub1.nat.tf 的后端是它自己的缓存集群,不是 docker.io。当一个客户端通过 hub1.nat.tf 拉镜像时,流量是:你 → hub1.nat.tf(它的后端,大概率是 Cloudflare Worker 或者自建机房) → 它自己的后端 cache。只有第一次拉某个新镜像时,它的后端才回去 docker.io 拉一次,之后所有用户都从它的 cache 拿。你拉镜像时的 TCP 出口 IP 根本不是 docker.io 看到的那个 IP——docker.io 看到的是 hub1.nat.tf 后端的 IP,而那个 IP 走的是"独立缓存"模式的限流策略,通常对单 IP 的拉取次数不敏感**。
Q:你前面用 docker.1ms.run 的时候,发现它返回的是假 manifest,这听起来很离谱,能展开说说吗?
A:我测的时候用 curl 拉 docker.1ms.run/v2/library/nginx/manifests/1.27-alpine,返回的 manifest 里 mediaType 和 digest 是错的——内容里带的是某个完全不相关镜像的 attestation。docker CLI 拿到这个 manifest 后,真正去拉对应 digest 的 blob 时才发现对不上,然后报错。这意味着 1ms.run 的"v2 endpoint"被某种 fronting 或者反代污染了,不是它自己写的服务。这事我没有继续追,因为我已经选了 hub1.nat.tf。但这个现象提醒我:不要看"列表上有"就信,实测一次再说。
Q:为什么我跟着你做完一遍,在我的机器上 hub1.nat.tf 还是很慢?
A:网络出口不一样,结果就不一样。我在广东电信宽带测的,你是上海/北京/海外,出口 IP、骨干网路由、TCP 重传率全部不一样。你自己再去 time docker pull hub1.nat.tf/library/alpine:3.19 跑一次,这是唯一权威的数据。如果确实慢,按本文第四节的方法自己再测 8 个源,选在你这台机器上最快的。
参考资料
- Docker Hub usage and rate limits — Docker 官方关于 100 pulls/6h/IP 限流的说明
- Distribution: Configuring a registry —
proxy.remoteurl、username、password字段的官方文档 - Distribution: Registry as a pull through cache — 官方 mirror recipe,包括"目前只支持单一 upstream"的 gotcha
- Docker Hub 限流响应头的含义 —
ratelimit-limit: 100;w=21600这个 header 的读法 - containerd: registry mirror caveats — 跟 daemon.json 配 mirror 相关的边角问题
最后:这次问题我最大的感受是 —— “慢” 永远不是一个简单的问题。它可以是 upstream 慢、可以是缓存没命中、可以是客户端限流、可以是网络出口被封、可以是 registry 协议握手失败。每一层都要分别测一次,才能确认根因。而一旦根因找对,解决方法往往只是一行 config + 一个 5 行的 cron。
—— 完 ——