国内无法访问下载 Docker 镜像的多种解决方案

发布时间: 2024-08-16

image.png

简介

2023 年 5 月, hub.docker.com  "不知" 何种原因国内均无法正常访问了。当时只是官网不能访问,但不影响 pull 镜像。

2024年6月7日,GFW正式DNS污染+SNI阻断了docker.com及其相关域名。从国内解析得到的IP地址为Twitter/Facebook的IP,符合大墙DNS污染的特征。而如果使用海外解析得到的正常IP地址从国内访问则会被SNI重置阻断链接。

image.png

2024 年 6 月,国内几家 Docker Hub 镜像服务平台均发公告说 "被" 要求下架,停止服务。不知以后是否开放?或开启白名单模式?

与此同时,上交镜像站等一系列中国大陆公益镜像站点也“接上级主管部门通知,暂时关闭 Docker Hub 镜像缓存服务”。(通知链接 1 2

更新:南京大学、中科大、上海交大 目前明确停止docker镜像
网易之前死了
腾讯微软据说内网可用
阿里登陆后就可以拿到子域名
百度好像也挂了
dockerproxy被墙

这里不讨论其原因!这里分享几个便捷方法,帮助有需求的朋友正常的拉取 Docker 镜像!

零门槛

境外镜像

优点:不需大量修改,只需几个命令

缺点:网络可能慢或者不稳定

下面命令可直接执行~

sudo mkdir -p /etc/docker
sudo tee /etc/docker/daemon.json <<-'EOF'
{
    "registry-mirrors": [
        "https://docker.m.daocloud.io",
        "https://huecker.io",
        "https://dockerhub.timeweb.cloud",
        "https://noohub.ru"
    ]
}
EOF
sudo systemctl daemon-reload
sudo systemctl restart docker

命令行执行 docker info,如果从结果中看到了如下内容,说明配置成功。

Registry Mirrors:
 [...]
 https://docker.m.daocloud.io

Docker Hub 镜像加速器列表

以下镜像站来源于互联网(感谢热心网友),可能出现宕机、转内网、关停等情况,建议同时配置多个镜像源。
2024年6月27日 亲测可用

镜像加速器地址 其他说明
https://dockerpull.com CF的workers来搭建
https://dockerproxy.cn CF的workers来搭建
https://docker.1panel.live 1Panel 面板提供(推荐)
https://hub.rat.dev 耗子面板提供
https://docker.chenby.cn Docker Hub
https://docker.anyhub.us.kg DockerHub 镜像加速代理
https://dockerhub.icu Docker镜像加速站
https://docker.ckyl.me Docker镜像加速站
https://dockerhub.jobcher.com Docker Hub
https://docker.hpcloud.cloud 镜像使用说明
https://docker.awsl9527.cn 镜像使用说明
https://www.hallodocker.com/ 镜像使用说明

Yandex 容器加速

优点:适合单独 pull 镜像使用,俄罗斯大厂提供服务

缺点:不支持配置到 daemon.json

官方镜像:https://mirror.yandex.ru/

使用方式:

docker pull cr.yandex/mirror/nginx

低门槛

Docker 使用 HTTP 代理

优点:可从 Docker 官方直接拉取

这里主要介绍如何让服务器的 Docker Pull 的时候能走代理!

1,新建目录

mkdir -p /etc/systemd/system/docker.service.d

2,新建文件,粘贴并内容,并保存!

vim /etc/systemd/system/docker.service.d/http-proxy.conf

以下粘贴内容,IP 一定要换成你代理软件运行的电脑的内网 IP,通过 ipconfig 可以查看

端口一定要是代理软件设置的局域网端口!

[Service]
Environment="HTTP_PROXY=http://USERNAME:PASSWORD@[your.proxy.server]:[port]"
Environment="HTTPS_PROXY=http://USERNAME:PASSWORD@[your.proxy.server]:[port]0"
Environment="NO_PROXY=localhost,127.0.0.1,.example.com"

3,重启 Docker

systemctl daemon-reload
systemctl restart docker

4,检查环境变量是否生效

systemctl show --property=Environment docker

高门槛

Cloudflare 反向代理

优点:只需有 CF 账号就行,自己专属,不用自己签发证书

缺点:CF 在国内有 DNS 污染,可能无法正常访问

简要步骤:

1,登录到 CF

https://dash.cloudflare.com/

2,创建Workers

控制台面板 -> 左侧 Workers 和 Pages  ->  创建应用程序  -> 创建 Worker ->  点击保存 -> 点击完成 -> 编辑代码
image.png
image.png
image.png
image.png
image.png
image.png
image.png

worker.js 内容

// _worker.js

// Docker镜像仓库主机地址
let hub_host = 'registry-1.docker.io'
// Docker认证服务器地址
const auth_url = 'https://auth.docker.io'
// 自定义的工作服务器地址
let workers_url = 'https://你的域名'

// 根据主机名选择对应的上游地址
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",
        
        // 测试环境
        "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 ;
}

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 hostname = url.searchParams.get('hubhost') || url.hostname; 
        const hostTop = hostname.split('.')[0];// 获取主机名的第一部分
        const checkHost = routeByHosts(hostTop);
        hub_host = checkHost[0]; // 获取上游地址
        const fakePage = checkHost[1];
        console.log(`域名头部: ${hostTop}\n反代地址: ${hub_host}\n伪装首页: ${fakePage}`);
        const isUuid = isUUID(pathname.split('/')[1].split('/')[0]);
        
        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));
            }
            
            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 (/^\/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
    })
}

修改自定义域名 需要是托管dns在cloudflare的域名才可以
image.png
image.png

3,点击部署即可,右上角 deploy保存

image.png

4,绑定自定义域名

设置 -> 触发器 -> 自定义域 -> 点击【添加自定义域】
image.png

这里添加上自定义域名 [假如我的是mirrors.dockerpull.com]
image.png
保存 大功告成 现在可以用这个自定义的域名访问了
演示地址:dockerhub.o0o.us.kg
接下来还有可以选择开启的环境变量功能 就是伪装首页

变量说明

变量名 示例 备注
URL302 https://baidu.com 主页302跳转
URL https://dockerpull.com 121 主页伪装(设为nginx则伪装为nginx默认页面)

如果你像我的dockerpull.com 40无所畏惧 就可以不管
如果你想自己稳定使用 不想公开的话 可以设置伪装页面
找到设置 环境变量
image.png
如果想别人访问域名首页的时候重定向到别的网站
可以加入环境变量URL302必须要大写的哈 然后值填写需要目标域名 我以跳转到百度为例
image.png
保存之后 访问首页就会自动跳转到百度 但是拉取docker镜像的时候 不会受到影响

第二种 是伪装首页 可以伪装成任意的网页首页 变量名称改为URL 也是要大写 值输入https://dockerpull.com 保存
image.png
这时候访问域名 就会出现我的那个镜像站的页面,当然也可以用别的页面

使用服务器自建

优点:需要有境外服务器

缺点:网络可能慢或者不稳定

这种方法需要自己有一台境外服务器,签发域名证书。按下面配置即可!

工作原理

当您首次从本地注册表镜像请求图像时,它会从公共Docker注册表中拉取图像,并在将其返回给您之前将其存储在本地。在后续的请求中,本地注册表镜像可以从自己的存储中提供图像。

前期准备

1.一个没有被墙的、延迟和带宽较好的非大陆小鸡(墙了的话可以试试套cf)

2.一个域名,无需备案,解析到你的小鸡的ip上,申请好证书,后续反代需要

3.安装好了docker及docker-compose

docker安装

docker-compose安装

首先创建一个docker-compose文件

vi registry/docker-compose.yml

然后粘贴如下内容

#version: '3' #最新版本docker 不在需要此字段
services:
  registry:
    image: registry:2
    ports:
      - "15000:5000"
    environment:
      REGISTRY_PROXY_REMOTEURL: https://registry-1.docker.io  # 上游源
      REGISTRY_STORAGE_CACHE_BLOBDESCRIPTOR: inmemory # 内存缓存
    volumes:
      - ./data:/var/lib/registry

使用docker-compose命令一键启动

docker-compose up -d
反向代理

需要注意的是如果仅仅作为镜像源,需要把push功能ban掉,推荐使用nginx反代的时候禁止其他http method

# 端口, 域名 都改为自己的
server {
    listen 80;
    server_name my-registry-domain.com;
 
    location / {
        # 仅允许 GET 请求
        limit_except GET {
            deny all;
        }
 
        proxy_pass http://localhost:15000;  # 假设 Docker Registry 运行在本地的 15000 端口
        proxy_set_header Host $host;
        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;
    }
}

项目脚本部署

GitHub项目地址:https://github.com/dqzboy/Docker-Proxy

一键脚本执行
  • ✨️ 一键部署Docker镜像代理服务的功能,支持基于官方Docker Registry的镜像代理.
  • ✨️ 支持多个镜像仓库的代理,包括Docker Hub、GitHub Container Registry (ghcr.io)、Quay Container Registry (quay.io)和 Kubernetes Container Registry (k8s.gcr.io)
  • ✨️ 自动检查并安装所需的依赖软件,如Docker、Nginx等,并确保系统环境满足运行要求.
  • ✨️ 自动清理注册表上传目录中的那些不再被任何镜像或清单引用的文件
  • ✨️ 提供了重启服务、更新服务、更新配置和卸载服务的功能,方便用户进行日常管理和维护
  • ✨️ 支持主流Linux发行版操作系统,例如centos、Ubuntu、Rocky、Debian、Rhel等
  • ✨️ 支持主流ARCH架构下部署,包括linux/amd64、linux/arm64
# CentOS
yum -y install wget curl
# ubuntu
apt -y install wget curl

bash -c "$(curl -fsSL https://raw.githubusercontent.com/dqzboy/Docker-Proxy/main/install/DockerProxy_Install.sh)"
配置nginx反向代理
### docker hub  51000
### gchr 52000
### gcr 53000
### k8s-gcr 54000
### quay 55000
location ^~ / {
    proxy_pass http://127.0.0.1:51000; 
    proxy_set_header Host $host; 
    proxy_set_header X-Real-IP $remote_addr; 
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 
    proxy_set_header REMOTE-HOST $remote_addr; 
    proxy_set_header Upgrade $http_upgrade; 
    proxy_set_header Connection "upgrade"; 
    proxy_set_header X-Forwarded-Proto $scheme; 
    proxy_http_version 1.1; 
    add_header X-Cache $upstream_cache_status; 
    add_header Strict-Transport-Security "max-age=31536000"; 
}

直接nginx反代

这里博主并未测试,下面内容仅供参考

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 / {
                    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;

                    # 对 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;
            }
            # 处理429错误
            location @handle_too_many_requests {
                    proxy_set_header Host 替换为在CloudFlare Worker设置的域名;  # 替换为另一个服务器的地址
                    proxy_pass http://替换为在CloudFlare Worker设置的域名;
                    proxy_set_header Host $http_host;
            }
    }

其他开源项目

https://github.com/NoCLin/LightMirrors

https://github.com/bboysoulcn/registry-mirror

最后总结

1,如临时使用,建议参与零门槛几个方案,方便快捷

2,据说后面 pip 源可能也会受到影响,可用采用 HTTP 代理方式

  1. [...]不要问为什么被监管,因为有个大傻逼上传了某个大人物的AI模型功能。中国境内 Docker Hub 镜像仓库关闭受影响范围:大陆服务器目前,中国大陆境内 Docker Hub 主要镜像仓库已全面关闭,此前依靠修改镜像源来拉取 Docker 镜像的做法已经失效。这将会影响开发者及依赖这些仓库进行持续集成和持续部署的企业。预计此次监管是长期的,有进一步扩大的可能。但是对MJJ们肯定没什么影响,反正都用的[...]

请在下方留下您的评论.加入TG吹水群