跳转至

自己动手搭建 uv python 智能代理站

之前有介绍在国内使用 uv 如何设置代理加速python 包的下载,在安装python 时,当时介绍了国内目前只有一个南京大学镜像站,https://mirror.nju.edu.cn/github-release/indygreg/python-build-standalone/, 这个下载站只镜像了 uv 最新的release 文件,所以对于之前月份的文件,如果使用南京大学下载站就会下载失败,本文介绍一种使用海外云服务搭建uv python 的下载代理站实现可以正常下载安装 uv python 的实现方案。

前言:为什么我们需要这个方案?

作为一名 Python 开发者,你一定遇到过这样的烦恼:想用超快的 uv 工具安装 Python,却发现国内访问 GitHub 慢如蜗牛,之前介绍过国内只有南京大学镜像站可用于加速下载,但该镜像站仅镜像了 uv 最新的 release 文件。别担心!今天我要分享的解决方案,能让你像使用国内镜像站一样流畅地安装任意版本的 uv Python!本文将详细介绍一种使用海外云服务搭建 uv Python 下载代理站的实现方案,让你轻松解决下载难题

[!warning] 本文仅限技术交流,请勿用于任何违法用途

uv 下载安装python 的逻辑

uv 在安装python 时默认从 github 上下载,基础下载路径地址如下

https://github.com/astral-sh/python-build-standalone/releases/download

如安装mac 下的python3.10.17, 下载地址为 https://github.com/astral-sh``/python-build-standalone/releases/download/20250409/cpython-3.10.17+20250409-x86_64-apple-darwin-debug-full.tar.zst

它是将/20250409 之后的内容拼接到基础下载路径,但是这个github 地址国内访问又经常失败,uv 提供了以个 --mirror 的参数,以及镜像环境变量 UV_PYTHON_INSTALL_MIRROR , uv 当检测到有这个值的时候,就将文件下载地址拼接到对应的基础路径,我们需要搭建一个代理服务,实现以下功能

  1. 先检查南京大学镜像站是否有文件,如果有就直接302到南京大学下载站

  2. 如果没有就从github 上下载,并流式的代理给客户端。

🚀 方案概览:智能代理的魔法

我们的方案就像一个聪明的快递小哥:

  1. 先敲门问南京大学:"你有这个版本的 Python 吗?"
  2. 如果有:直接告诉你去南京大学取货(302 跳转)
  3. 如果没有:小哥亲自去 GitHub 帮你取货,然后送到你手上
1
2
3
请求 → 校验 → 镜像检查 → GitHub回源 → 流式传输
        │             │
        └─ 302跳转     └─ 代理下载

🔧 技术选型:为什么是 OpenResty?

目前有两种方案可以实施:

  1. 使用 OpenResty 配合 Lua 脚本,检测南京大学是否有相应文件,并配合做相应的跳转与代理。

  2. 使用开源的 gh-proxy 项目做代理。

我比较偏向于第一种方案,因为可以不用开发服务端的代码,且 OpenResty 是基于 Nginx 的,本身性能比较高。gh-proxy 是使用 Python Flask 框架开发的下载代理项目,也有很多 GitHub 下载代理站使用,当然如果对于 Python 很熟悉的话,这个项目部署起来也很方便。

OpenResty 三大优势让你无法拒绝

  1. 性能怪兽 🚗💨
  2. 基于 Nginx,轻松应对高并发
  3. LuaJIT 让脚本运行快如闪电

  4. 零代码开发 🧑‍💻

  5. 只需配置 + Lua 小脚本
  6. 不用写复杂的后端服务

  7. 灵活如猫 🐱

  8. 动态决定跳转或代理
  9. 轻松添加新规则
方案 优点 缺点
OpenResty 高性能、灵活、零代码依赖 需学习 Lua 语法
gh-proxy 开箱即用,无需开发 功能固定,无法自定义逻辑(如优先镜像站)

🛠️ 手把手搭建指南

第一步:安装 OpenResty

这里我写了一个一键安装脚本

#!/bin/bash

# 检查是否是root用户
if [ "$(id -u)" -ne 0 ]; then
  echo "必须以root用户运行脚本!"
  exit 1
fi

echo "开始安装 OpenResty 和 lua-resty-http..."

# 1. 安装 EPEL 和必要工具
echo "安装必要的工具..."
yum install -y yum-utils wget curl

# 2. 添加 OpenResty YUM 仓库
echo "添加 OpenResty 仓库..."
yum-config-manager --add-repo https://openresty.org/package/centos/openresty.repo

# 3. 安装 OpenResty
echo "安装 OpenResty..."
yum install -y openresty

# 4. 启动 OpenResty
echo "启动 OpenResty..."
systemctl start openresty
systemctl enable openresty

# 5. 安装 OPM(OpenResty Package Manager)
echo "安装 OpenResty 包管理器 OPM..."
yum install -y openresty-opm

# 6. 安装 lua-resty-http
echo "安装 lua-resty-http..."
/usr/local/openresty/bin/opm get ledgetech/lua-resty-http

# 7. 检查 OpenResty 是否安装成功
echo "检查 OpenResty 版本..."
openresty -v

# 8. 检查 lua-resty-http 是否安装成功
echo "检查 lua-resty-http 是否安装成功..."
if [ -f "/usr/local/openresty/site/lualib/resty/http.lua" ]; then
  echo "lua-resty-http 安装成功!"
else
  echo "lua-resty-http 安装失败!"
  exit 1
fi


# 10. 重启 OpenResty
echo "重启 OpenResty..."
systemctl restart openresty

echo "安装完成!"

由于是国外的服务器,下载安装还是很快的。

第二步:配置 Nginx 核心

nginx 核心配置文件路径为 /usr/local/openresty/nginx/conf/nginx.conf 修改这个文件如下:

#user  nobody;
worker_processes  1;

error_log  logs/error.log info;
#error_log  logs/error.log  notice;
#error_log  logs/error.log  info;

#pid        logs/nginx.pid;


events {
    worker_connections  1024;
}


http {
    resolver 8.8.8.8 8.8.4.4 ipv6=off;
    include       mime.types;
    default_type  application/octet-stream;

    proxy_buffer_size 16k;
    proxy_buffers 8 128k;            # 8 × 128k = 1MB 缓冲池
    proxy_busy_buffers_size 768k;    # 约占75%,提高发送速度
    proxy_connect_timeout 5s;
    proxy_read_timeout 600s;
    send_timeout 600s;

    sendfile        on;
    #tcp_nopush     on;

    #keepalive_timeout  0;
    keepalive_timeout  65;

    #gzip  on;
    include /usr/local/openresty/nginx/conf/default.d/*.conf;
    server {
        listen       80;
        server_name  localhost;


        error_page   500 502 503 504  /50x.html;
        location = /50x.html {
            root   html;
        }
    }
}

修改几个buffer 配置,github 返回的header 头信息还是挺多挺大的

1
2
3
proxy_buffer_size 16k;
proxy_buffers 8 128k; 
proxy_busy_buffers_size 768k; 

添加 dns,屏蔽掉ipv6 配置

resolver 8.8.8.8 8.8.4.4 ipv6=off;

添加网站配置文件目录, 表示将一下目录所有以 .conf 文件加载进来。

include /usr/local/openresty/nginx/conf/default.d/*.conf;

第三步:编写智能代理逻辑

/usr/local/openresty/nginx/conf/default.d/ 创建一个uvproxy.conf 文件,写入以下内容

server {
    listen 8081;
    set $target_backend "";
    set $redirect_target "";
    location / {
        content_by_lua_block {
            local remote_ip = ngx.var.remote_addr
            -- 校验日期是否合法的函数
            local function is_valid_date(date_str)
                if not date_str or #date_str ~= 8 then
                    ngx.log(ngx.INFO, "date  " .. date_str .. " is not a valid date")
                    return false
                end
                local year = tonumber(string.sub(date_str, 1, 4))
                local month = tonumber(string.sub(date_str, 5, 6))
                local day = tonumber(string.sub(date_str, 7, 8))
                ngx.log(ngx.INFO, year .." ".. month.." "..day)

                if not (year and month and day) then
                    return false
                end

                if month < 1 or month > 12 then
                    ngx.log(ngx.INFO, "month is not valid month:" .. month)
                    return false
                end

                local days_in_month = {
                    31, (year % 4 == 0 and (year % 100 ~= 0 or year % 400 == 0)) and 29 or 28,
                    31, 30, 31, 30,
                    31, 31, 30, 31, 30, 31
                }

                if day < 1 or day > days_in_month[month] then
                    ngx.log(ngx.INFO, "day is not a valid day: ".. day)
                    return false
                end

                return true
            end

            local http = require "resty.http"
            local uri = ngx.var.request_uri
            -- 检查是否以 /YYYYMMDD 开头
            local m = ngx.re.match(uri, [[^/(\d{8})(/.*)?$]])
            if not m then
                ngx.log(ngx.INFO, "data is not valid data: ".. uri)
                ngx.status = ngx.HTTP_FORBIDDEN
                ngx.say("403 Forbidden: Invalid URI start")
                return ngx.exit(ngx.HTTP_FORBIDDEN)
            end

            local date_part = m[1]
            local rest_path = m[2] or ""

            -- 检查日期是否合法
            if not is_valid_date(date_part) then
                ngx.log(ngx.INFO, "data not valid data: ".. uri)
                ngx.status = ngx.HTTP_FORBIDDEN
                ngx.say("403 Forbidden: Invalid date format")
                return ngx.exit(ngx.HTTP_FORBIDDEN)
            end

            -- 检查扩展名
            local allowed_exts = {[".sha256"]=true, [".gz"]=true, [".zst"]=true, [""]=true}
            local ext = ""

            do
                local ext_m = ngx.re.match(rest_path, [[\.([a-z0-9]+)$]], "ijo")
                if ext_m then
                    ext = "." .. ext_m[1]
                end
            end

            if not allowed_exts[ext] then
                ngx.log(ngx.INFO, "ext not valid:"..ext)
                ngx.status = ngx.HTTP_FORBIDDEN
                ngx.say("403 Forbidden: Invalid file extension")
                return ngx.exit(ngx.HTTP_FORBIDDEN)
            end


            local httpc = http.new()

            -- 先请求 南京大学
            local nju_base = "https://mirror.nju.edu.cn/github-release/indygreg/python-build-standalone"
            local github_base = "https://github.com/astral-sh/python-build-standalone/releases/download"

            -- 构造请求的完整 URL
            local nju_url = nju_base .. uri
            local githuburl = github_base .. uri
            ngx.log(ngx.INFO, "check nju URL: " .. nju_url)
            local res, err = httpc:request_uri(nju_url, {
                method = "HEAD",
                ssl_verify = false,
                headers = {
                    ["Host"] = "mirror.nju.edu.cn",
                }
            })
            if res and res.status == 200 then
                -- 返回200
                -- 打印请求的 URL  error.log
                ngx.log(ngx.INFO, "use nju URL: " .. nju_base)
                ngx.redirect(nju_url, 302)
            else
                -- 使用github 地址
                res, err = httpc:request_uri(githuburl, {
                    method = "HEAD",
                    ssl_verify = false,
                }) 
                if res.status == 302 then
                    local location = res.headers["Location"]
                    if location then
                        ngx.log(ngx.INFO, "302 redirect detected, proxying to: " .. location)
                        -- 解析完整 URL(返回 scheme、host、port、path、query)
                        local function parse_url(url)
                            local parsed = {}

                            parsed.scheme, parsed.rest = url:match("^(https?)://(.+)$")
                            if not parsed.scheme then return nil, "invalid URL" end

                            parsed.host, parsed.port, parsed.path = parsed.rest:match("^([^:/]+):?(%d*)(/?.*)$")
                            parsed.port = tonumber(parsed.port)
                            if parsed.path == "" then parsed.path = "/" end

                            -- 提取查询字符串
                            if parsed.path:find("?") then
                                parsed.path, parsed.query = parsed.path:match("^([^?]+)%??(.*)$")
                            end

                            return parsed
                        end
                        -- 流式下载函数
                        local function stream_proxy(location)
                            local parsed_url, err = parse_url(location)
                            if not parsed_url then
                                ngx.log(ngx.ERR, "URL parse failed: ", err)
                                return ngx.exit(ngx.HTTP_NOT_FOUND)
                            end


                            local scheme = parsed_url.scheme or "https"
                            local host = parsed_url.host
                            local port = tonumber(parsed_url.port) or (scheme == "https" and 443 or 80)
                            local path = parsed_url.path or "/"
                            if parsed_url.query then
                                path = path .. "?" .. parsed_url.query
                            end

                            local httpc = http.new()
                            httpc:set_timeout(10000)
                            local ok, err = httpc:connect(host, port)
                            if not ok then
                                ngx.log(ngx.ERR, "connect failed: ", err)
                                return ngx.exit(ngx.HTTP_NOT_FOUND)
                            end

                            if scheme == "https" then
                                local ok, err = httpc:ssl_handshake(nil, host, false)
                                if not ok then
                                    ngx.log(ngx.ERR, "ssl handshake failed: ", err)
                                    return ngx.exit(ngx.HTTP_NOT_FOUND)
                                end
                            end

                            local res, err = httpc:request({
                                method = "GET",
                                path = path,
                                headers = {
                                    ["Host"] = host,
                                    ["User-Agent"] = "Mozilla/5.0"
                                }
                            })

                            if not res then
                                ngx.log(ngx.ERR, "proxy request failed: ", err)
                                return ngx.exit(ngx.HTTP_NOT_FOUND)
                            end

                            -- 转发响应头
                            for k, v in pairs(res.headers) do
                                if k:lower() ~= "transfer-encoding" and k:lower() ~= "connection" then
                                    ngx.header[k] = v
                                end
                            end

                            ngx.status = res.status

                            -- 流式传输内容
                            while true do
                                local chunk, err = res.body_reader()
                                if err then
                                    ngx.log(ngx.ERR, "read error: ", err)
                                    break
                                end
                                if not chunk then break end
                                ngx.print(chunk)
                                ngx.flush(true)
                            end

                            httpc:set_keepalive()
                            return ngx.exit(res.status)
                        end
                        return stream_proxy(location)
                    else
                        ngx.log(ngx.ERR, "302 received but no Location header")
                        -- 返回失败
                        return ngx.exit(ngx.HTTP_NOT_FOUND)
                    end

                else
                    -- 这里也直接返回失败
                    ngx.log(ngx.ERR, "github status unknow:" .. res.status)
                    return ngx.exit(ngx.HTTP_NOT_FOUND)
                end
            end
        }
    }
}

这个脚本比较长,简单介绍一下实现的过程

为了避免一些不必要的请求,会对下载地址做一些校验,对于非法的请求直接返回403,所以有以下规则

  1. 由于 uv python 的下载路径是以 /20250409 这样的日期格式开头的路径,所以这里先校验了一下格式是否正确,同时这里定义了一个is_valid_date函数,校验日期是否合法。

  2. 检查下载路径是否是 .sha256, .gz, .zst 或者空

  3. 之后拼接南京大学的下载地址,请求南京大学镜像站,注意这里使用的是HEAD 请求,并不会真正的下载文件,只是获取一下头信息,这个头信息很重要,它告诉客户端是否服务器上是否有该请求资源,如果是需要302跳转的,会将跳转地址写到响应头信息中

  4. 如果南京大学镜像站有该文件,即http返回200,nginx 直接返回302,告诉客户端去这个地址下载

  5. 如果没有,则拼接github 下载url, 再次以HEAD 方式请求访问github 资源,注意,如果github 有这个资源,一般情况下是会返回一个302 下载地址,因为github 会将文件存储在cdn 网络上。

  6. 当github 返回302 时,注意这里不能像南京大学那样,也用302将文件下载地址返回给客户端,因为github 返回的302 下载地址一般国内也是下载不到了,所以这里需要使用这台代理服务器下载该文件,并返回给客户端

  7. 之后代理服务器以流式的方式获取文件,并且以流式的方式返回给客户端

  8. 如果github 返回404,则直接返回给客户端404

💡 核心技术揭秘

1. 为什么不能用简单的 proxy_pass?

GitHub 返回的是带签名的临时 S3 地址:

https://objects.githubusercontent.com/...?X-Amz-Algorithm=...&X-Amz-Expires=300...

这些参数如果使用proxy_pass 会被 Nginx 截断,导致签名失效 → 404 错误!

2. github 返回的地址为什么不能直接302给客户端

南京大学镜像站如果文件,我们是直接302跳转的,但是github 却不行,国内请求github 文件之所以慢,主要是由于github 返回的cdn 地址也在国外,国内下载是很慢或者下载不到的,如果github 下载地址也返回302,客户端将直接访问这个国外的cdn地址,那么就和没有使用代理一样。

3. 为什么要使用流式传输 ✨

代理下载github 文件有两种方式

  1. 代理服务器先将文件全部下载下来,然后再返回给客户端(传统方式)

  2. 代理服务器一边下载一边将内容返回给客户端

两种方式的比较如下:

传统方式 我们的方式
全部下载完再发送 像水管一样边下载边传输
内存占用高 几乎不占额外内存
用户等待时间长 用户立即开始下载
所以这里我采用了边下边播的方式
-- 流式传输内容
while true do
    local chunk, err = res.body_reader()
    if err then
        ngx.log(ngx.ERR, "read error: ", err)
        break
    end
    if not chunk then break end
    ngx.print(chunk)
    ngx.flush(true)
end

🎯 效果测试

假设你的服务器IP是 1.1.1.1,测试命令:

UV_PYTHON_INSTALL_MIRROR=http://1.1.1.1:8001 uv python install 3.10

观察日志,你会看到: 1. 优先尝试南京大学镜像 2. 找不到时自动切换 GitHub 代理 3. 大文件流畅下载不卡顿

📦 资源获取

所有配置文件和脚本已开源,有兴趣可以参考一下: 👉 https://github.com/kevinkelin/uvproxy

通过这个方案,你现在可以: ✅ 享受国内镜像站的速度
✅ 访问 GitHub 上的所有历史版本
✅ 完全掌控自己的开发环境

是不是有种"原来如此"的畅快感?赶紧动手试试吧!遇到问题欢迎留言交流~

⚠️ 最后的重要提醒

[!danger] 重要提醒 1. 技术无罪,用法有责!请严格遵守法律法规,仅将本方案用于合法合规的技术研究和个人使用。 2. 海外服务器要遵守当地法律法规 3. 大流量使用请注意服务器带宽