自建Docker Hub加速镜像

在使用Docker的过程中会大量的涉及Pull镜像的操作,但是由于官方的镜像服务器在国外再加上某个防火墙的阻拦,导致直接拉取镜像非常困难(看脸)。所以通常的操作是设置一个由国内厂商、机构提供的加速镜像,来提高拉取镜像的速度。但是随着Docker hub限制了未注册用户的拉取频率、各大厂商、机构开始将加速镜像转为内部使用,个人用户拉取镜像变得越来越困难。在长期拉取镜像速度看脸的头疼之下,尝试通过 Nginx 和 Cloudflare Worker 两种方案以及两种方案的组合方案自建Docker hub加速镜像来解决这个问题

更新日志

  1. 2024 年 09 月 12 日
    auth.docker.io 被墙,增加反代 auth.docker.io 的配置
    官方的镜像仓库无论是否登录,都会要求认证,而当我们使用反代了官方仓库的镜像源拉取镜像时,官方仓库会首先返回一个 401 Unauthorized 响应,并附带一个 WWW-Authenticate 头,其中包含了获取认证所需的服务地址和授权范围(scope)。而这个“获取认证所需的服务地址”默认是 auth.docker.io。但是这个域名今天已经被封禁了,进而影响了我们拉取镜像。
    在经过测试后发现可以通过重写这一 WWW-Authenticate 头,将认证请求导向我们自己的反代,交由反代服务器去访问 auth.docker.io,这样就规避了对 auth.docker.io 的封禁。

可选的加速方案

  1. 使用公共的加速镜像 国内的 Docker Hub 镜像加速器,由国内教育机构与各大云服务商提供的镜像加速服务
    • 速度通常不错,但由于 Docker hub 限制了未注册用户的拉取频率,可能会导致加速镜像无法及时同步最新的镜像(例如 阿里云镜像源
    • 由于很高的成本,可能会导致一些原本提供了镜像源的厂商、机构停止提供(如 163、七牛云等)或转向仅为内网内的设备和用户提供服务(如腾讯云、中国科学技术大学开源软件镜像 等)
  2. 使用代理拉取镜像 如何配置 docker 通过代理服务器拉取镜像。简单来说就是通过设置由 systemd 管理的 docker daemon 的环境变量来实现利用代理拉取镜像
    • 优点自然就是带宽几乎没有限制,并且镜像不会缓存,不会下载到未更新的镜像
    • 缺点是需要设备上或局域网内有现成的代理且一些特殊设备(例如威联通、群晖)经过了厂商魔改难以手动设置环境变量,没有加速镜像的通用型强,需要每个设备单独设置
  3. 间接拉取镜像。在境外机器已经配置了代理的设备上拉取镜像,再将镜像导出、传输到需要使用镜像的设备上、最后导入。
    • 优点…能用
    • 缺点自然就是很麻烦,需要提前准备好传输文件的方案

最后整合上面我使用过的几个拉取镜像的方案,最后的选择是自建一个加速镜像,免得以后的到处找能用的加速镜像源

自建加速镜像

在尝试搭建之前找了挺多资料,主流的方式有:使用官方提供的 registry,第三方的 Nexus、Harbor。但是使用 registry 搭建一直没有成功,客户端一直报找不到指定镜像;使用 Nexus 搭建又有些太复杂。最后自己总结出来了这两个比较方便的方案。

一点小发现:
在使用 Nginx 搭建时,发现服务器的流量很小,经过检查 Nginx 的日志后发现,Docker hub 镜像仓库返回的下载地址是需要 307 跳转的,而跳转后的地址直连依然下载很慢,所以需要在服务端处理这个跳转,由服务端请求并将跳转后的数据返回客户端。

方案一:使用 Nginx 搭建

系统:Ububtu 22.04 服务器:Zgocloud 洛杉矶 9929+CMIN2
提到要加速一个网站,自然就能想到使用 Nginx 反代一下了。接下来是具体的配置方案

  1. 安装 Nginx

    sudo apt update
    sudo apt install nginx
    
  2. 防火墙放行指定端口
    我这里使用的防火墙是系统自带的 UFW,并且没有开厂商提供的防火墙(忘记是关掉了还是本来就没有,反正没开),所以只需要 sudo ufw allow ‘Nginx Full’ 一条命令即可,这样就会放行 IPv4 和 IPv6 的 80 和 443 端口(当然也可以手动 sudo ufw allow 443 这样只开放 443 端口)
    如果没有使用防火墙,就不用设置这一步,如果还使用了厂商提供的防火墙,就需要在厂商的面板处同样开放这些端口

  3. 配置 Nginx 使用命令 sudo vim /etc/nginx/nginx.conf 编辑 Nginx 配置,在 http 块下增加一个 server 块

    /etc/nginx/nginx.conf

    #反代docker hub镜像源
         server {
                 listen 443 ssl;
                 server_name 域名;
    
                 ssl_certificate 证书地址;
                 ssl_certificate_key 密钥地址;
    
                 ssl_session_timeout 24h;
                 ssl_ciphers 'ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256';
                 ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3;
    
                 location /v2/ {
                         proxy_pass https://registry-1.docker.io;  # Docker Hub 的官方镜像仓库
                         proxy_set_header Host registry-1.docker.io;
                         proxy_set_header X-Real-IP $remote_addr;
                         proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
                         proxy_set_header X-Forwarded-Proto $scheme;
    
                         # 关闭缓存
                         proxy_buffering off;
    
                         # 转发认证相关的头部
                         proxy_set_header Authorization $http_authorization;
                         proxy_pass_header  Authorization;
    
                         # 重写 www-authenticate 头为你的反代地址
                         proxy_hide_header www-authenticate;
                         add_header www-authenticate 'Bearer realm="https://域名/token",service="registry.docker.io"' always;
                         # always 参数确保该头部在返回 401 错误时无论什么情况下都会被添加。
    
                         # 对 upstream 状态码检查,实现 error_page 错误重定向
                         proxy_intercept_errors on;
                         # error_page 指令默认只检查了第一次后端返回的状态码,开启后可以跟随多次重定向。
                         recursive_error_pages on;
                         # 根据状态码执行对应操作,以下为301、302、307状态码都会触发
                         error_page 301 302 307 = @handle_redirect;
    
                 }
                 # 处理 Docker OAuth2 Token 认证请求
                 location /token {
                     resolver 1.1.1.1 valid=600s;
                     proxy_pass https://auth.docker.io;  # Docker 认证服务器
    
                     # 设置请求头,确保转发正确
                     proxy_set_header Host auth.docker.io;
                     proxy_set_header X-Real-IP $remote_addr;
                     proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
                     proxy_set_header X-Forwarded-Proto $scheme;
    
                     # 传递 Authorization 头信息,获取 Token
                     proxy_set_header Authorization $http_authorization;
                     proxy_pass_header Authorization;
    
                     # 禁用缓存
                     proxy_buffering off;
                 }
                 location @handle_redirect {
                         resolver 1.1.1.1;
                         set $saved_redirect_location '$upstream_http_location';
                         proxy_pass $saved_redirect_location;
                 }
         }
    

    然后按 Esc ,输入 : wq 保存退出即可

  4. 重新加载 Nginx 配置 输入命令 sudo nginx -s reload,没有报错就说明配置已经生效

方案二:使用 CloudFlare Worker 搭建

作为一个贫穷(doge)的用户,可以免费使用的 CloudFlare Worker 自然要想方设法的用用了,虽然 CloudFlare Worker 的访问速度在国内也不算稳定,但在 CloudFlare 的边缘网络的加持下,白天的速度还是非常可观的,晚上会比较慢但还是比直接使用官方的镜像源要快上很多(又不要钱,要啥自行车.jpg)

这里是使用的 基于 Cloudflare Worker 的容器镜像加速器 ,更详细的教程可以参考 2024 自建 Docker 镜像代理加速 教程来了 3 分钟部署完毕

简单教程:

  1. 在面板左侧找到 Workers 和 Pages,然后点击右侧的 创建应用程序创建 Worker,修改一个好记的名字,部署

  2. 接下来编辑代码,将 worker.js 的内容替换为下面内容,并且修改 工作服务器地址 为一个 dns 托管在 cloudflare 的域名,例如 let workers_url = ‘https://lty520.faith/';

    worker.js

     // _worker.js
    
     // Docker镜像仓库主机地址
     let hub_host = 'registry-1.docker.io';
     // Docker认证服务器地址
     const auth_url = 'https://auth.docker.io';
     // 自定义的工作服务器地址
     let workers_url = 'https://xxx/';
    
     let 屏蔽爬虫UA = ['netcraft'];
    
     // 根据主机名选择对应的上游地址
     function routeByHosts(host) {
         // 定义路由表
         const routes = {
             // 生产环境
             "quay": "quay.io",
             "gcr": "gcr.io",
             "k8s-gcr": "k8s.gcr.io",
             "k8s": "registry.k8s.io",
             "ghcr": "ghcr.io",
             "cloudsmith": "docker.cloudsmith.io",
             "nvcr": "nvcr.io",
    
             // 测试环境
             "test": "registry-1.docker.io",
         };
    
         if (host in routes) return [ routes[host], false ];
         else return [ hub_host, true ];
     }
    
     /** @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;
     }
    
     async function searchInterface() {
         const text = `
         <!DOCTYPE html>
         <html>
         <head>
             <title>Docker Hub Search</title>
             <style>
             body {
                 font-family: Arial, sans-serif;
                 display: flex;
                 flex-direction: column;
                 align-items: center;
                 justify-content: center;
                 height: 100vh;
                 margin: 0;
                 background: linear-gradient(to right, rgb(28, 143, 237), rgb(29, 99, 237));
             }
             .logo {
                 margin-bottom: 20px;
             }
             .search-container {
                 display: flex;
                 align-items: center;
             }
             #search-input {
                 padding: 10px;
                 font-size: 16px;
                 border: 1px solid #ddd;
                 border-radius: 4px;
                 width: 300px;
                 margin-right: 10px;
             }
             #search-button {
                 padding: 10px;
                 background-color: rgba(255, 255, 255, 0.2); /* 设置白色,透明度为10% */
                 border: none;
                 border-radius: 4px;
                 cursor: pointer;
                 width: 44px;
                 height: 44px;
                 display: flex;
                 align-items: center;
                 justify-content: center;
             }			
             #search-button svg {
                 width: 24px;
                 height: 24px;
             }
             </style>
         </head>
         <body>
             <div class="logo">
             <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 18" fill="#ffffff" width="100" height="75">
                 <path d="M23.763 6.886c-.065-.053-.673-.512-1.954-.512-.32 0-.659.03-1.01.087-.248-1.703-1.651-2.533-1.716-2.57l-.345-.2-.227.328a4.596 4.596 0 0 0-.611 1.433c-.23.972-.09 1.884.403 2.666-.596.331-1.546.418-1.744.42H.752a.753.753 0 0 0-.75.749c-.007 1.456.233 2.864.692 4.07.545 1.43 1.355 2.483 2.409 3.13 1.181.725 3.104 1.14 5.276 1.14 1.016 0 2.03-.092 2.93-.266 1.417-.273 2.705-.742 3.826-1.391a10.497 10.497 0 0 0 2.61-2.14c1.252-1.42 1.998-3.005 2.553-4.408.075.003.148.005.221.005 1.371 0 2.215-.55 2.68-1.01.505-.5.685-.998.704-1.053L24 7.076l-.237-.19Z"></path>
                 <path d="M2.216 8.075h2.119a.186.186 0 0 0 .185-.186V6a.186.186 0 0 0-.185-.186H2.216A.186.186 0 0 0 2.031 6v1.89c0 .103.083.186.185.186Zm2.92 0h2.118a.185.185 0 0 0 .185-.186V6a.185.185 0 0 0-.185-.186H5.136A.185.185 0 0 0 4.95 6v1.89c0 .103.083.186.186.186Zm2.964 0h2.118a.186.186 0 0 0 .185-.186V6a.186.186 0 0 0-.185-.186H8.1A.185.185 0 0 0 7.914 6v1.89c0 .103.083.186.186.186Zm2.928 0h2.119a.185.185 0 0 0 .185-.186V6a.185.185 0 0 0-.185-.186h-2.119a.186.186 0 0 0-.185.186v1.89c0 .103.083.186.185.186Zm-5.892-2.72h2.118a.185.185 0 0 0 .185-.186V3.28a.186.186 0 0 0-.185-.186H5.136a.186.186 0 0 0-.186.186v1.89c0 .103.083.186.186.186Zm2.964 0h2.118a.186.186 0 0 0 .185-.186V3.28a.186.186 0 0 0-.185-.186H8.1a.186.186 0 0 0-.186.186v1.89c0 .103.083.186.186.186Zm2.928 0h2.119a.185.185 0 0 0 .185-.186V3.28a.186.186 0 0 0-.185-.186h-2.119a.186.186 0 0 0-.185.186v1.89c0 .103.083.186.185.186Zm0-2.72h2.119a.186.186 0 0 0 .185-.186V.56a.185.185 0 0 0-.185-.186h-2.119a.186.186 0 0 0-.185.186v1.89c0 .103.083.186.185.186Zm2.955 5.44h2.118a.185.185 0 0 0 .186-.186V6a.185.185 0 0 0-.186-.186h-2.118a.185.185 0 0 0-.185.186v1.89c0 .103.083.186.185.186Z"></path>
             </svg>
             </div>
             <div class="search-container">
             <input type="text" id="search-input" placeholder="Search Docker Hub">
             <button id="search-button">
                 <svg focusable="false" aria-hidden="true" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
                 <path d="M21 21L16.65 16.65M19 11C19 15.4183 15.4183 19 11 19C6.58172 19 3 15.4183 3 11C3 6.58172 6.58172 3 11 3C15.4183 3 19 6.58172 19 11Z" stroke="white" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
                 </svg>
             </button>
             </div>
             <script>
             function performSearch() {
                 const query = document.getElementById('search-input').value;
                 if (query) {
                 window.location.href = '/search?q=' + encodeURIComponent(query);
                 }
             }
    
             document.getElementById('search-button').addEventListener('click', performSearch);
             document.getElementById('search-input').addEventListener('keypress', function(event) {
                 if (event.key === 'Enter') {
                 performSearch();
                 }
             });
             </script>
         </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
             const userAgentHeader = request.headers.get('User-Agent');
             const userAgent = userAgentHeader ? userAgentHeader.toLowerCase() : "null";
             if (env.UA) 屏蔽爬虫UA = 屏蔽爬虫UA.concat(await ADD(env.UA));
             workers_url = `https://${url.hostname}`;
             const pathname = url.pathname;
    
             // 获取请求参数中的 ns
             const ns = url.searchParams.get('ns'); 
             const hostname = url.searchParams.get('hubhost') || url.hostname;
             const hostTop = hostname.split('.')[0]; // 获取主机名的第一部分
    
             let checkHost; // 在这里定义 checkHost 变量
             // 如果存在 ns 参数,优先使用它来确定 hub_host
             if (ns) {
                 if (ns === 'docker.io') {
                     hub_host = 'registry-1.docker.io'; // 设置上游地址为 registry-1.docker.io
                 } else {
                     hub_host = ns; // 直接使用 ns 作为 hub_host
                 }
             } else {
                 checkHost = routeByHosts(hostTop);
                 hub_host = checkHost[0]; // 获取上游地址
             }
    
             const fakePage = checkHost ? checkHost[1] : false; // 确保 fakePage 不为 undefined
             console.log(`域名头部: ${hostTop}\n反代地址: ${hub_host}\n伪装首页: ${fakePage}`);
             const isUuid = isUUID(pathname.split('/')[1].split('/')[0]);
    
             if (屏蔽爬虫UA.some(fxxk => userAgent.includes(fxxk)) && 屏蔽爬虫UA.length > 0) {
                 // 首页改成一个nginx伪装页
                 return new Response(await nginx(), {
                     headers: {
                         'Content-Type': 'text/html; charset=UTF-8',
                     },
                 });
             }
    
             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) && (fakePage === true || hostTop == 'docker')) {
                 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));
                 } else if (url.pathname == '/'){
                     return new Response(await searchInterface(), {
                         headers: {
                         'Content-Type': 'text/html; charset=UTF-8',
                         },
                     });
                 }
    
                 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.includes('/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 ( hub_host == 'registry-1.docker.io' && /^\/v2\/[^/]+\/[^/]+\/[^/]+$/.test(url.pathname) && !/^\/v2\/library/.test(url.pathname)) {
                 //url.pathname = url.pathname.replace(/\/v2\//, '/v2/library/');
                 url.pathname = '/v2/library/' + url.pathname.split('/v2/')[1];
                 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
         });
     }
    
     async function ADD(envadd) {
         var addtext = envadd.replace(/[	 |"'\r\n]+/g, ',').replace(/,+/g, ',');	// 将空格、双引号、单引号和换行符替换为逗号
         if (addtext.charAt(0) == ',') addtext = addtext.slice(1);
         if (addtext.charAt(addtext.length - 1) == ',') addtext = addtext.slice(0, addtext.length - 1);
         const add = addtext.split(',');
         return add;
     }
    
     ```
    
  3. 接下来,点击右上角的 部署,稍等片刻

  4. 最后,返回面板,在 设置触发器 处设置一个自己的域名,一切就大功告成了
    不建议使用自带的 workers.dev 的域名,被墙了

方案一、二整合

本来上面的两个方案是独立的,一个使用 Nginx 部署,一个使用 CloudFlare Worker 部署,但是就在我写这篇博客的时候,突然想到,为什么不能把上面的两个方案整合起来呢?
利用服务器搭建的 Nginx 作为中转,优先由服务器直连 Docker hub 的官方仓库,当服务器的 IP 请求次数超限后(会报 429 错误),就把请求转发到 CloudFlare Worker 部署的镜像源上,利用 CloudFlare Worker 再做一次中转。这样就即保证了使用服务器中转提高速度,又保证了不会因为服务器的 IP 请求速度过多而受限制,唯一的限制就是服务器的带宽和流量了,几乎完美!!!

部署方法:
将上面部署的 Nginx 配置替换为下面的配置并使用 sudo nginx -s reload 重新加载即可

/etc/nginx/nginx.conf

#反代docker hub镜像源
    server {
            listen 443 ssl;
            server_name 域名;

            ssl_certificate 证书地址;
            ssl_certificate_key 密钥地址;

            proxy_ssl_server_name on; # 启用SNI

            ssl_session_timeout 24h;
            ssl_ciphers 'ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256';
            ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3;

            location /v2/ {
                    proxy_pass https://registry-1.docker.io;  # Docker Hub 的官方镜像仓库

                    proxy_set_header Host registry-1.docker.io;
                    proxy_set_header X-Real-IP $remote_addr;
                    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
                    proxy_set_header X-Forwarded-Proto $scheme;

                    # 关闭缓存
                    proxy_buffering off;

                    # 转发认证相关的头部
                    proxy_set_header Authorization $http_authorization;
                    proxy_pass_header  Authorization;

                    # 重写 www-authenticate 头为你的反代地址
                    proxy_hide_header www-authenticate;
                    add_header www-authenticate 'Bearer realm="https://域名/token",service="registry.docker.io"' always;
                    # always 参数确保该头部在返回 401 错误时无论什么情况下都会被添加。

                    # 对 upstream 状态码检查,实现 error_page 错误重定向
                    proxy_intercept_errors on;
                    # error_page 指令默认只检查了第一次后端返回的状态码,开启后可以跟随多次重定向。
                    recursive_error_pages on;
                    # 根据状态码执行对应操作,以下为301、302、307状态码都会触发
                    #error_page 301 302 307 = @handle_redirect;

                    error_page 429 = @handle_too_many_requests;
            }
            # 处理 Docker OAuth2 Token 认证请求
            location /token {
                resolver 1.1.1.1 valid=600s;
                proxy_pass https://auth.docker.io;  # Docker 认证服务器

                # 设置请求头,确保转发正确
                proxy_set_header Host auth.docker.io;
                proxy_set_header X-Real-IP $remote_addr;
                proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
                proxy_set_header X-Forwarded-Proto $scheme;

                # 传递 Authorization 头信息,获取 Token
                proxy_set_header Authorization $http_authorization;
                proxy_pass_header Authorization;

                # 禁用缓存
                proxy_buffering off;
            }
            #处理重定向
            location @handle_redirect {
                    resolver 1.1.1.1;
                    set $saved_redirect_location '$upstream_http_location';
                    proxy_pass $saved_redirect_location;
            }
            # 处理429错误
            location @handle_too_many_requests {
                    proxy_set_header Host 替换为在CloudFlare Worker设置的域名;  # 替换为另一个服务器的地址
                    proxy_pass http://替换为在CloudFlare Worker设置的域名;
                    proxy_set_header Host $http_host;
            }
    }

如果想要反代 ghcr 镜像源呢?只要参考上面的配置,将域名、header 修改一下即可
并且因为 ghcr 好像不像 docker hub 有下载频率的限制,所以也不用去 Cloudflare Worker 部署了,直接在服务器上部署一个就行。

#反代ghcr镜像源
server {
        listen 443 ssl;
        server_name 域名;

        ssl_certificate 证书地址;
        ssl_certificate_key 密钥地址;
        proxy_ssl_server_name on;
        ssl_protocols TLSv1.1 TLSv1.2 TLSv1.3;
        #error_log /home/ubuntuago/proxy_docker.log debug;
        if ($blocked_agent) {
                return 403;
        }

        location / {
                proxy_pass https://ghcr.io;  # Docker Hub 的官方镜像仓库

                proxy_set_header Host ghcr.io;
                proxy_set_header X-Real-IP $remote_addr;
                proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
                proxy_set_header X-Forwarded-Proto $scheme;

                # 关闭缓存
                proxy_buffering off;

                # 转发认证相关的头部
                proxy_set_header Authorization $http_authorization;
                proxy_pass_header  Authorization;
                # 对 upstream 状态码检查,实现 error_page 错误重定向
                proxy_intercept_errors on;
                # error_page 指令默认只检查了第一次后端返回的状态码,开启后可以跟随多次重定向。
                recursive_error_pages on;
                # 根据状态码执行对应操作,以下为301、302、307状态码都会触发
                error_page 301 302 307 = @handle_redirect;

                #error_page 429 = @handle_too_many_requests;

        }
        #处理重定向
        location @handle_redirect {
                resolver 1.1.1.1;
                set $saved_redirect_location '$upstream_http_location';
                proxy_pass $saved_redirect_location;
        }

}

新增的一些代码的作用:

  1. proxy_ssl_server_name on;
    HTTPS, 需要在握手时候验证证书, 所以在握手时候需要将域名告诉对方, 找到匹配的证书, 这就是 SNI 的工作. 否则会导致证书找不到而请求失败.
    默认情况下, nginx 并不会开启 proxy_ssl_server_name, 也就是说不启用 SNI. 如果使用 nginx 反代一个虚拟主机的服务, 比如 Cloudflare Workers, 此时如果不开启 SNI, 会导致与 CF 握手时候, CF 并不清楚请求哪一个域名下的服务, 所以找不到匹配的证书, 因此会报 502 错误. 当然也可以使用 proxy_ssl_name 字段复写于最终服务器收到的域名.
    引自:SNI
    在尝试整合这两个方案的时候不知道这个参数,一直报 502..人都麻了
  2. error_page 429 = @handle_too_many_requests;
    当错误代码为 429 时(即请求次数超限)转发到在 Cloudflare Workers 部署的镜像源
  3. proxy_set_header Host 替换为在 CloudFlare Worker 设置的域名;
    将发给 CloudFlare Worker 的请求加上正确的域名方可让请求到达我们搭建的镜像源,如果不加会报 404

镜像源使用方式

配置镜像源的方式在网上有很多,这里不做赘述
但是使用“方案一”、“方案一、二整合”的方式搭建后,如果要使用我们的反代登录到官方仓库,应在 docker login 后增加反代服务器的域名,例如 docker login example.com 然后再正常输入 docker hub 的用户名、密码。

注意!如果利用反代服务器登录 docker hub 仓库,反代服务器是可以获取到你的账号密码的,所以请谨慎使用他人搭建的服务登录!
如果没有拉取自己的私有仓库的镜像的需求,保持匿名拉取相对会安全一些。

各种加速镜像实测

测试系统为 Debian12,测试镜像为 uptime-kuma: latest
使用命令 time docker pull 加速镜像地址/louislam/uptime-kuma 测试
测试时间为 2024 年 04 月 16 日 22 时

  1. dockerproxy.com Docker 镜像代理
    用时 19.370s
  2. docker.m.daocloud.io DaoCloud 镜像站
    用时 13.927s
  3. docker.mirrors.sjtug.sjtu.edu.cn 上海交大镜像站
    用时 12.157s
  4. docker.nju.edu.cn 南京大学镜像站
    用时 12.690s
  5. Nginx 反代自建方案
    1. 处理重定向
      • 晚上十点测试,用时 16.342s
      • 次日下午五点测试,用时 15.432s
    2. 不处理重定向
      次日下午五点测试,虽然不会出现使用官方仓库一样的卡死,但极慢
  6. CloudFlare Worker 反代自建方案
    • 上午十一点测试,用时 15.731s
    • 晚高峰几乎无法使用
  7. 直连官方镜像仓库
    几乎无法使用,完全看脸
    完全无法使用

总结

根据测试,自建一个 Docker hub 加速镜像完全可行,使用 Nginx 搭建会比较看重服务器的线路,但只要不算太差,就完全可用了。不处理跳转会比较节省流量,但是为了速度,节省这一点流量也没什么必要。有一定的成本,但是如果本身就已经买了服务器的话,搭建个镜像源就几乎是顺带的事情了。
而使用 CloudFlare Worker 搭建..只能说稍微好一点点,如果服务器需要大量拉取镜像的话或许会用到这个方案。
而将方案一、二整合,几乎就是现阶段最完美的方案了,速度快且不受拉取次数的限制,只要 Docker hub 别乱修改拉取镜像的流程,理论上可以用超久。


参考文章


最后修改于 2024-04-16