绕过从 Docker Hub pull 镜像时的 429 toomanyrequests

很早很早以前,我们都使用裸机部署应用,部署应用无非以下几步:

  1. 安装操作系统
  2. 安装运行环境(PHP,NodeJS,Ruby, etc)
  3. 安装/复制程序
  4. 尝试运行程序
  5. 报错,修环境问题
  6. 继续报错,修开发人员
  7. 开发人员提交一个很脏的 Hack,应用终于跑起来了

直到容器化的出现,开发和运维开发将整个程序和运行环境放在一个个 Docker Image 和 docker-compose.yml 中,启动一个程序已经慢慢缩减成了一行 docker run 或者 docker-compose up -d,绿色无害,迁移方便,使用起来让人上瘾,想不断地使用 Docker,并不断将 Docker 融入自己的 Workflow 中,然而,Docker 用的多了,就会看到以下情况:

Error response from daemon: toomanyrequests: You have reached your pull rate limit. You may increase the limit by authenticating and upgrading: https://www.docker.com/increase-rate-limit

打开这个页面,你就会知道,从 2020-11-02 开始,官方的 Docker Hub 开始对 pull 请求加上了限制,限制为匿名用户(未登录),每 6 小时只能拉 100 image,登录的免费用户每 6 小时拉 200 镜像:

The rate limits of 100 container image requests per six hours for anonymous usage, and 200 container image requests per six hours for free Docker accounts are now in effect.

对于登录而言,限制的是用户,对于未登录的用户而言,限制的是 IP。

Docker 要怡烂钱我可以理解, 但是 docker hub 上面 ubuntu:latest 这样的基础镜像都给我整一个 rate-limit 我是真的没想到。 不知道今天起有多少人的 CI/CD 会因为这个挂掉。

——https://twitter.com/IceCode8964XI/status/1328395263606628352

这怎么行?

Docker pull 背后的原理

由于限制的是 pull 请求,为了摆脱这种限制,我们首先得了解 docker pull 背后到底做了啥,然后推测限制的位置并绕过。

Docker Hub 的地址

我们虽然日常访问的是 https://hub.docker.com ,但是我们在 https://github.com/docker/distribution/blob/master/reference/normalize.go#L13 中可以看到实际 docker 使用的地址是一个硬编码的 docker.io

var (
	legacyDefaultDomain = "index.docker.io"
	defaultDomain       = "docker.io"
	officialRepoName    = "library"
	defaultTag          = "latest"
)

pull 的流程

在 Docker 的 API 文档: https://docs.docker.com/registry/spec/api/#pulling-an-image 中,我们知道:

An “image” is a combination of a JSON manifest and individual layer files. The process of pulling an image centers around retrieving these two components.

一个 docker pull 指令会从拉两部分,一部分是 manifest,一部分是 layer,前者指定了一个 image 相关的信息和 layer 的信息(一个 JSON 文件),后者就是一些大文件(layer),从我们内部统计的情况来看,后者普遍使用的是 https://production.cloudflare.docker.com/,这部分应该是不会受到限制的,所以猜测限制的地方是前者 manifest 的部分的请求,从文档 https://docs.docker.com/docker-hub/download-rate-limit/ 中我们也可以知道:

A pull request is defined as up to two GET requests on registry manifest URLs (/v2//manifests/).

故而证实了我们猜测,Docker Hub 是在拉 manifest 的过程中进行限制的。

那么 manifest 是从哪儿拉的?

manifest 地址

由于没有地方记录了 docker pull 的时候到底是从哪儿拉的地址,需要 MITM 一下:

Flows
   GET https://registry-1.docker.io/v2/
       ← 401 application/json 87b 213ms
   GET https://auth.docker.io/token?account=youraccount&scope=repository%3Alibrary%2Fal
       pine%3Apull&service=registry.docker.io
       ← 200 application/json 4.18k 245ms
>> GET https://registry-1.docker.io/v2/library/alpine/manifests/latest
       ← 200 application/vnd.docker.distribution.manifest.list.v2+json 1.6k 294ms
   GET https://registry-1.docker.io/v2/library/alpine/manifests/sha256:57334c50959f26ce
       1ee025d08f136c2292c128f84e7b229d1b0da5dac89e9866
       ← 200 application/vnd.docker.distribution.manifest.v2+json 528b 326ms
   GET https://registry-1.docker.io/v2/library/alpine/blobs/sha256:b7b28af77ffec6054d13
       378df4fdf02725830086c7444d9c278af25312aa39b9
       ← 307 text/html 242b 288ms
   GET https://registry-1.docker.io/v2/library/alpine/blobs/sha256:0503825856099e6adb39
       c8297af09547f69684b7016b7f3680ed801aa310baaa
       ← 307 text/html 242b 322ms
   GET https://production.cloudflare.docker.com/registry-v2/docker/registry/v2/blobs/sh
       a256/b7/b7b28af77ffec6054d13378df4fdf02725830086c7444d9c278af25312aa39b9/data?…
       ← 200 application/octet-stream 1.48k 191ms
   GET https://production.cloudflare.docker.com/registry-v2/docker/registry/v2/blobs/sh
       a256/05/0503825856099e6adb39c8297af09547f69684b7016b7f3680ed801aa310baaa/data?…
       ← 200 application/octet-stream 2.66m 207ms
⇩  [27/32]                                                                     [*:8080]

我们会发现 https://registry-1.docker.io/v2/ 这个地址,通过手动改 Host + 调路由的方式并重新 pull 发现可以成功之后得到了验证。

接下来就是给这个地址设置用不同的 IP 来请求这个地址即可绕开限制,比如..用 Tor。

后记

刷 Docker Hub 流量固然很快乐,但是我们的主要目的还是保护内部 CI 不挂,且提升 pull 的速度,所以这个时候配置一个 pull-through cache 才是一个比较合理的解决方式,嗷对了,如果你和我一样使用了自己的 cache 的话,记得改外部 DNS 设置,而不是容器内的 /etc/hosts ,不然容器内的程序还是会通过 Host 的 DNS 去查询 registry-1.docker.io 的 IP 并直连,让你继续看到 toomanyrequests。

References

  1. Get manifest of a public Docker image hosted on Docker Hub using the Docker Registry API