解决 Docker 在国内无法顺畅使用的问题
前段时间国内的 Docker Hub 加速缓存站在短时间内全部下线,为了仍然能在网络受限的环境下正常使用 Docker 的各项功能,我们需要修改 Docker 的部分配置文件,或使用来自「霍格沃茨」的能力闪现至长城之外……
本篇博文的首页封面图由 OpenAI DALL·E 生成。
怎么回事?
让我们来回顾一下事件的全过程:
在2024年6月6日,上海交通大学 Linux 用户组 (SJTUG) 在其网站上发布了一条公告,通知使用了 SJTUG Docker Hub mirror 的用户迁移其服务。 原始链接
2024-06-06-上交大镜像站发布公告下架 Docker Hub 镜像站
同一天,中国科学技术大学开源软件镜像站也发出了同样的公告。 原始链接
2024-06-06-中科大镜像站发布公告关闭 Docker Hub 镜像缓存服务, GCR 与 Quay 不受影响
又过了一天,南京大学镜像站的 Docker Hub 缓存也下线。 原始链接
2024-06-07-南大镜像站 Docker Hub 缓存正常运行
2024-06-07-南大镜像站 Docker Hub 缓存消失
原百度 BCE 镜像也停止解析,至此,大陆地区所有可公开访问的 Docker Hub 镜像站均已下线。
经过在本地和 itdog.cn 上的测试,Docker 主站点和 Docker Hub 在国内均是无法直接访问的(DNS污染和SNI阻断)。
当然,即使镜像站消失了,Docker 全站也无法直连,但这不代表任务进行不下去,研究就可以不做了。
那具体该怎么办呢?
解决方案有两种:
以下将对以上三种方法逐一介绍,难度按顺序由浅入深。
为 Docker 配置海外缓存站
这是最简单的一种办法,只需要少量修改就能用了
根据Docker 守护进程配置概述 - 配置文件的表格,请依据自己的实际使用环境,编辑以下配置文件。
文档中的表格内容翻译如下
操作系统和配置 文件位置 Linux, regular setup (传统安装,Linux一般是这种) /etc/docker/daemon.jsonLinux, rootless mode (非特权模式) ~/.config/docker/daemon.jsonWindows C:\ProgramData\docker\config\daemon.json此处以 Linux 传统安装 为例。
用你喜欢的文本编辑器打开
/etc/docker/daemon.json,文件不存在就新建文件,并将以下文本加入其中:1
2
3
4
5
6
7
8{
"registry-mirrors": [
"https://docker.m.daocloud.io",
"https://huecker.io",
"https://dockerhub.timeweb.cloud",
"https://noohub.ru"
]
}你也可以将你找到的缓存站加入以上列表。
重启 docker engine:
1
2sudo systemctl daemon-reload
sudo systemctl restart docker然后就可以正常拉取镜像啦。😆
咱们来随便拉个mysql试试:1
2
3
4
5
6
7
8
9
10
11
12
13
14$ sudo docker pull mysql:8.0.37-bookworm
8.0.37-bookworm: Pulling from library/mysql
2cc3ae149d28: Pull complete
22d5d3c999e7: Pull complete
028261070555: Pull complete
90facb54927d: Pull complete
8ac805783dbd: Pull complete
f83473c07644: Extracting 5.014MB
c7cf26312880: Download complete
d829cc689d14: Download complete
cb4001b741c1: Downloading 26.4MB
3662b243cb4a: Download complete
65a44116a5c5: Download complete
3b0138779dff: Download complete但是,这种方法有个许多缺点。例如,有些缓存站在海外,国内访问速度不佳;你无法对缓存站执行
docker push操作;因为使用人数较多,缓存站也可能会有速率限制。所以本文更推荐接下来这种方法。
给 Docker 使用云梯
都会用 Docker 了该不会还没有云梯吧?不会吧不会吧…
Docker Engine 23.0 或以上版本可以经过以下几个步骤为 Docker 配置代理服务器:
首先,检查 Docker Engine 版本。
1
sudo docker version例如,我的 Docker Engine Server 版本为 26.1.4
1
2
3
4
5
6
7
8
9
10
11...
Server: Docker Engine - Community
Engine:
Version: 26.1.4
API version: 1.45 (minimum version 1.24)
Go version: go1.21.11
Git commit: de5c9cf
Built: Wed Jun 5 11:29:22 2024
OS/Arch: linux/amd64
Experimental: false
...由此可见,我的 Docker Engine 版本符合要求。
根据上一节的内容,用你喜欢的文本编辑器打开 Docker 守护进程的配置文件,文件不存在就新建文件,并将以下文本(或其中的
proxies配置)加入其中:1
2
3
4
5
6
7{
"proxies": {
"http-proxy": "http://<你的代理服务器地址>:<代理服务器端口>",
"https-proxy": "http://<你的代理服务器地址>:<代理服务器端口>",
"no-proxy": "localhost,127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,100.64.0.0/10,fd00::/8"
}
}这里配置了
no-proxy用以忽略一些专用网络地址、链路本地地址和 localhost ,你可以根据自己的需求修改。其中
http-proxy和http-proxy中填写的就是你对代理服务器地址,假设你使用的是 Clash 或类似软件,那么你应该填写为127.0.0.1:7890,具体的端口号不同的云梯程序会有所不同,你应该按照自己的实际情况修改。我的配置:
1
2
3
4
5
6
7{
"proxies": {
"http-proxy": "http://127.0.0.1:7890",
"https-proxy": "http://127.0.0.1:7890",
"no-proxy": "localhost,127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,100.64.0.0/10,fd00::/8"
}
}保存文件并重启 Docker daemon:
1
sudo systemctl restart docker.service有人说,我的 Docker Engine 版本低于 23.0 怎么办😰,难道我要被抛弃了吗?😭
当然不会,现今大部分用户使用的 Linux 发行版均将 systemd 作为默认的守护进程管理程序,因此按照 Docker 的官方文档使用 systemd 配置守护进程,我们还可以通过修改 systemd 服务文件的环境变量配置来配置代理的功能。首先,为 docker 服务创建一个 systemd 插入目录:
1
sudo mkdir -p /etc/systemd/system/docker.service.d创建一个名为
/etc/systemd/system/docker.service.d/http-proxy.conf的文件,添加HTTP_PROXY环境变量:1
2
3
4[Service]
Environment="HTTP_PROXY=http://<你的代理服务器地址>:<代理服务器端口>"
Environment="HTTPS_PROXY=https://<你的代理服务器地址>:<代理服务器端口>"
Environment="NO_PROXY=localhost,127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,100.64.0.0/10,fd00::/8"然后刷新 systemd daemon 并重启 Docker Engine:
1
2sudo systemctl daemon-reload
sudo systemctl restart docker随便拉一个镜像试试:
1
2
3
4
5
6
7
8
9
10
11
12
13
14$ sudo docker pull mysql:8.0.37-bookworm
8.0.37-bookworm: Pulling from library/mysql
2cc3ae149d28: Pull complete
22d5d3c999e7: Pull complete
028261070555: Pull complete
90facb54927d: Pull complete
8ac805783dbd: Pull complete
f83473c07644: Pull complete
c7cf26312880: Pull complete
d829cc689d14: Pull complete
cb4001b741c1: Extracting 84.67MB/134.4MB
3662b243cb4a: Download complete
65a44116a5c5: Download complete
3b0138779dff: Download complete可以正常拉取了,挺好。😜
自己建一个 Docker Hub 缓存站
若手里服务器比较多,这似乎也是个合理的利用方法😏
如果在实验室里设备比较多,你又没有一个可用流量巨大的代理服务器时,第二种方法就会出现其局限性。
(而且实验室里的电脑不全是我们自己的,结果都挂上自己的云梯,那多多少少会有些合规性问题。老师同意且支持当我没说)
利用 Cloudflare Workers 建立反向代理
这种方法只需要你拥有一个 Cloudflare 账号(免费版套餐即可)和一个域名。
简要步骤:
登录到 Cloudflare 控制面板
依次点击左侧的 Workers & Pages -> Create Worker -> 随便改个名字 -> Save -> Finish -> Edit Code
然后把下面的代码粘贴到左侧的编辑器里:▶[点击释放] CF 反向代理的代码1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296'use strict'
// Docker镜像仓库主机地址
const hub_host = 'registry-1.docker.io'
// Docker认证服务器地址
const auth_url = 'https://auth.docker.io'
// 自定义的工作服务器地址
let workers_url = 'https://你的域名'
/**
* 静态文件 (404.html, sw.js, conf.js)
* ref: https://global.v2ex.com/t/1007922
*/
/** @type {RequestInit} */
const PREFLIGHT_INIT = {
// 预检请求配置
headers: new Headers({
'access-control-allow-origin': '*', // 允许所有来源
'access-control-allow-methods': 'GET,POST,PUT,PATCH,TRACE,DELETE,HEAD,OPTIONS', // 允许的HTTP方法
'access-control-max-age': '1728000', // 预检请求的缓存时间
}),
}
/**
* 构造响应
* @param {any} body 响应体
* @param {number} status 响应状态码
* @param {Object<string, string>} headers 响应头
*/
function makeRes(body, status = 200, headers = {}) {
headers['access-control-allow-origin'] = '*' // 允许所有来源
return new Response(body, { status, headers }) // 返回新构造的响应
}
/**
* 构造新的URL对象
* @param {string} urlStr URL字符串
*/
function newUrl(urlStr) {
try {
return new URL(urlStr) // 尝试构造新的URL对象
} catch (err) {
return null // 构造失败返回null
}
}
function isUUID(uuid) {
// 定义一个正则表达式来匹配 UUID 格式
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[4][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
// 使用正则表达式测试 UUID 字符串
return uuidRegex.test(uuid);
}
async function nginx() {
const text = `
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
<style>
body {
width: 35em;
margin: 0 auto;
font-family: Tahoma, Verdana, Arial, sans-serif;
}
</style>
</head>
<body>
<h1>Welcome to nginx!</h1>
<p>If you see this page, the nginx web server is successfully installed and
working. Further configuration is required.</p>
<p>For online documentation and support please refer to
<a href="http://nginx.org/">nginx.org</a>.<br/>
Commercial support is available at
<a href="http://nginx.com/">nginx.com</a>.</p>
<p><em>Thank you for using nginx.</em></p>
</body>
</html>
`
return text ;
}
export default {
async fetch(request, env, ctx) {
const getReqHeader = (key) => request.headers.get(key); // 获取请求头
let url = new URL(request.url); // 解析请求URL
workers_url = `https://${url.hostname}`;
const pathname = url.pathname;
const isUuid = isUUID(pathname.split('/')[1]);
const conditions = [
isUuid,
pathname.includes('/_'),
pathname.includes('/r'),
pathname.includes('/v2/user'),
pathname.includes('/v2/orgs'),
pathname.includes('/v2/_catalog'),
pathname.includes('/v2/categories'),
pathname.includes('/v2/feature-flags'),
pathname.includes('search'),
pathname.includes('source'),
pathname === '/',
pathname === '/favicon.ico',
pathname === '/auth/profile',
];
if (conditions.some(condition => condition)) {
if (env.URL302){
return Response.redirect(env.URL302, 302);
} else if (env.URL){
if (env.URL.toLowerCase() == 'nginx'){
//首页改成一个nginx伪装页
return new Response(await nginx(), {
headers: {
'Content-Type': 'text/html; charset=UTF-8',
},
});
} else return fetch(new Request(env.URL, request));
}
const newUrl = new URL("https://registry.hub.docker.com" + pathname + url.search);
// 复制原始请求的标头
const headers = new Headers(request.headers);
// 确保 Host 头部被替换为 hub.docker.com
headers.set('Host', 'registry.hub.docker.com');
const newRequest = new Request(newUrl, {
method: request.method,
headers: headers,
body: request.method !== 'GET' && request.method !== 'HEAD' ? await request.blob() : null,
redirect: 'follow'
});
return fetch(newRequest);
}
// 修改包含 %2F 和 %3A 的请求
if (!/%2F/.test(url.search) && /%3A/.test(url.toString())) {
let modifiedUrl = url.toString().replace(/%3A(?=.*?&)/, '%3Alibrary%2F');
url = new URL(modifiedUrl);
console.log(`handle_url: ${url}`)
}
// 处理token请求
if (url.pathname === '/token') {
let token_parameter = {
headers: {
'Host': 'auth.docker.io',
'User-Agent': getReqHeader("User-Agent"),
'Accept': getReqHeader("Accept"),
'Accept-Language': getReqHeader("Accept-Language"),
'Accept-Encoding': getReqHeader("Accept-Encoding"),
'Connection': 'keep-alive',
'Cache-Control': 'max-age=0'
}
};
let token_url = auth_url + url.pathname + url.search
return fetch(new Request(token_url, request), token_parameter)
}
// 修改 /v2/ 请求路径
if (/^\/v2\/[^/]+\/[^/]+\/[^/]+$/.test(url.pathname) && !/^\/v2\/library/.test(url.pathname)) {
url.pathname = url.pathname.replace(/\/v2\//, '/v2/library/');
console.log(`modified_url: ${url.pathname}`)
}
// 更改请求的主机名
url.hostname = hub_host;
// 构造请求参数
let parameter = {
headers: {
'Host': hub_host,
'User-Agent': getReqHeader("User-Agent"),
'Accept': getReqHeader("Accept"),
'Accept-Language': getReqHeader("Accept-Language"),
'Accept-Encoding': getReqHeader("Accept-Encoding"),
'Connection': 'keep-alive',
'Cache-Control': 'max-age=0'
},
cacheTtl: 3600 // 缓存时间
};
// 添加Authorization头
if (request.headers.has("Authorization")) {
parameter.headers.Authorization = getReqHeader("Authorization");
}
// 发起请求并处理响应
let original_response = await fetch(new Request(url, request), parameter)
let original_response_clone = original_response.clone();
let original_text = original_response_clone.body;
let response_headers = original_response.headers;
let new_response_headers = new Headers(response_headers);
let status = original_response.status;
// 修改 Www-Authenticate 头
if (new_response_headers.get("Www-Authenticate")) {
let auth = new_response_headers.get("Www-Authenticate");
let re = new RegExp(auth_url, 'g');
new_response_headers.set("Www-Authenticate", response_headers.get("Www-Authenticate").replace(re, workers_url));
}
// 处理重定向
if (new_response_headers.get("Location")) {
return httpHandler(request, new_response_headers.get("Location"))
}
// 返回修改后的响应
let response = new Response(original_text, {
status,
headers: new_response_headers
})
return response;
}
};
/**
* 处理HTTP请求
* @param {Request} req 请求对象
* @param {string} pathname 请求路径
*/
function httpHandler(req, pathname) {
const reqHdrRaw = req.headers
// 处理预检请求
if (req.method === 'OPTIONS' &&
reqHdrRaw.has('access-control-request-headers')
) {
return new Response(null, PREFLIGHT_INIT)
}
let rawLen = ''
const reqHdrNew = new Headers(reqHdrRaw)
const refer = reqHdrNew.get('referer')
let urlStr = pathname
const urlObj = newUrl(urlStr)
/** @type {RequestInit} */
const reqInit = {
method: req.method,
headers: reqHdrNew,
redirect: 'follow',
body: req.body
}
return proxy(urlObj, reqInit, rawLen)
}
/**
* 代理请求
* @param {URL} urlObj URL对象
* @param {RequestInit} reqInit 请求初始化对象
* @param {string} rawLen 原始长度
*/
async function proxy(urlObj, reqInit, rawLen) {
const res = await fetch(urlObj.href, reqInit)
const resHdrOld = res.headers
const resHdrNew = new Headers(resHdrOld)
// 验证长度
if (rawLen) {
const newLen = resHdrOld.get('content-length') || ''
const badLen = (rawLen !== newLen)
if (badLen) {
return makeRes(res.body, 400, {
'--error': `bad len: ${newLen}, except: ${rawLen}`,
'access-control-expose-headers': '--error',
})
}
}
const status = res.status
resHdrNew.set('access-control-expose-headers', '*')
resHdrNew.set('access-control-allow-origin', '*')
resHdrNew.set('Cache-Control', 'max-age=1500')
// 删除不必要的头
resHdrNew.delete('content-security-policy')
resHdrNew.delete('content-security-policy-report-only')
resHdrNew.delete('clear-site-data')
return new Response(res.body, {
status,
headers: resHdrNew
})
}以上代码来自这里,感谢作者开源
点击 Deploy
绑定域名
点击 Settings -> Triggers -> Add Custom Domain ,里面填写你的域名,再次点击 Add Custom Domain
等待你的域名初始化完毕,然后就可以试试能不能用啦。用法很简单,把你的域名替换到为 Docker 配置海外缓存站一节中的配置文件第一个即可。然后重启 Docker,Enjoy it!
利用 Nginx 反向代理
这需要你有一台境外的服务器,并且 IP 归属地不是伊朗朝鲜或俄罗斯。你还需要有一个域名,并且要为这个域名签发证书。
这种直接反向代理会是否出现什么问题谁也不知道(
丑话在前:如果搞了这个,导致你的域名或者 IP 什么时候因为反代 Docker Hub 被 GFW 封了别找我
以下是一个示例 Nginx 配置,你可以按需修改:
1 | |
然后再按照为 Docker 配置海外缓存站一节中的配置方法,将你自己的缓存加入列表中即可。
差不多就这样
当然,以上介绍的方法是比较常见的几种解决 Docker 使用障碍的办法,我自己使用的方法是为 Docker 配置代理服务器,这种方式最稳定快速,没有镜像站的无法使用推送功能的弊端,还不用担心缓存站因为负载过大失效导致无法拉起镜像的问题。
GitHub上也有非常多的自建 Mirror 的程序,也可以作为一种参考。