🚀 白嫖指南:在 Hugging Face Spaces 免费部署 Python Flask 应用 (Docker 模式)

前言

在这个 Heroku 收费、Oracle 难注册、各种 PaaS 平台休眠严重的时代,想找一个免费、稳定、无需绑定信用卡的地方托管 Python 脚本真的太难了。

经过多次踩坑和测试,我发现 Hugging Face Spaces (抱脸) 配合 Docker 是目前的版本答案。虽然它是做 AI 的,但它本质上提供了一个免费的容器服务,非常适合跑各类 Python 小工具、API 代理或轻量级 Web 应用。

这篇文章将手把手教你如何用 Docker 方式部署一个 Flask 应用,并以“M3U8 流媒体代理”为例。


✅ 为什么选择 Hugging Face Spaces?

  • 完全免费:注册仅需邮箱,不需要信用卡
  • 硬件不错:免费层提供 2 vCPU + 16GB 内存(共享资源,跑脚本绰绰有余)。
  • 支持 Docker:这意味着环境完全由你掌控,不用担心依赖冲突。
  • 相对稳定:只要有流量就不会休眠,冷启动速度也很快。

🛠 第一步:创建 Space

  1. 注册并登录 Hugging Face
  2. 点击右上角头像,选择 New Space
  3. 填写配置(关键步骤)
  • Space Name: 给项目起个名(例如 flask)。
  • License: 随便选,例如 MIT
  • Space SDK: 一定要选 Docker (不要选 Streamlit 或 Gradio)。
  • Docker Template: 选 Blank (空白模板)。
  • Space Hardware: 保持默认的 Free
  1. 点击 Create Space

📂 第二步:准备三个核心文件

进入 Space 的 Files 页面,我们需要创建四个文件。你可以直接在网页上 + Add file -> Create a new file,也可以在本地写好上传。

1. requirements.txt (依赖清单)

告诉 Docker 需要安装哪些 Python 库。

flask
requests
urllib3

2. Dockerfile (容器构建说明)

这是部署成功的关键。Hugging Face 对权限有要求,建议使用下面的标准模板。

# 使用官方轻量级 Python 镜像
FROM python:3.9-slim

# 设置工作目录
WORKDIR /app

# 复制依赖文件并安装
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# 复制当前目录下所有文件到容器
COPY . .

# 【重要】赋予权限,解决 HF 的 Permission Denied 问题
RUN chmod -R 777 /app

# 【重要】暴露 7860 端口 (HF 强制规定)
EXPOSE 7860

# 启动命令
CMD ["python", "app.py"]

3. templates/index.html (创建 HTML 页面)

重点来了:在文件名输入框里,不要只写 index.html,而是手动输入: templates/index.html (当你输入斜杠 / 时,HF 会自动帮你创建一个文件夹)

页面内容(一个好看的IOS风格播放器测试页):

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>HF NBA 代理</title>
    <style>
        body {
            /* 背景图 */
            background-image: url('https://images.unsplash.com/photo-1567158186373-2cdd94855eac?q=80&w=1920&auto=format&fit=crop');
            background-size: cover;
            background-position: center;
            background-repeat: no-repeat;
            background-attachment: fixed;
            
            font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", Arial, sans-serif;
            display: flex;
            flex-direction: column;
            align-items: center;
            justify-content: center;
            height: 100vh;
            margin: 0;
            box-shadow: inset 0 0 0 2000px rgba(0, 0, 0, 0.4); /* 加深一点背景遮罩,突出Logo */
        }
        .container {
            text-align: center;
            padding: 2.5rem;
            width: 85%;
            max-width: 450px;
            /* 方案 A:极致通透 */
            background-color: rgba(20, 20, 20, 0.2); 
            backdrop-filter: blur(10px) saturate(150%);
            -webkit-backdrop-filter: blur(10px) saturate(150%);
            border: 1px solid rgba(255, 255, 255, 0.2);
            
            border-radius: 30px;
            box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
            
            /* 让内容平滑过渡 */
            transition: all 0.3s ease;
        }
        /* 球队 Logo 样式 */
        .team-logo {
            width: 100px;
            height: 100px;
            object-fit: contain;
            margin-bottom: 10px;
            filter: drop-shadow(0 4px 6px rgba(0,0,0,0.3)); /* 给 Logo 加投影 */
            transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); /* 弹跳动画 */
        }
        
        .team-logo.active {
            transform: scale(1.1); /* 选中时放大一下 */
        }
        h1 { 
            color: #fff;
            margin: 10px 0 5px 0;
            font-weight: 700;
            font-size: 1.5rem;
            text-shadow: 0 2px 4px rgba(0,0,0,0.5);
        }
        p { 
            color: rgba(255, 255, 255, 0.6);
            margin-bottom: 25px; 
            font-size: 0.9rem;
        }
        select {
            width: 100%;
            padding: 16px;
            background-color: rgba(0, 0, 0, 0.4);
            border: 1px solid rgba(255, 255, 255, 0.1);
            color: white;
            border-radius: 14px;
            margin-bottom: 15px;
            font-size: 16px;
            outline: none;
            appearance: none; 
            -webkit-appearance: none;
            cursor: pointer;
            text-align: center;
            font-weight: 500;
        }
        
        .select-wrapper {
            position: relative;
            width: 100%;
        }
        .select-wrapper::after {
            content: '▼';
            font-size: 12px;
            color: rgba(255, 255, 255, 0.5);
            position: absolute;
            right: 20px;
            top: 50%;
            transform: translateY(-50%);
            pointer-events: none;
        }
        select option {
            background-color: #1c1c1e;
            color: white;
            padding: 10px;
        }
        input {
            width: 100%;
            box-sizing: border-box;
            padding: 16px;
            background: rgba(0, 0, 0, 0.25);
            border: 1px solid rgba(255, 255, 255, 0.1);
            color: rgba(255, 255, 255, 0.8);
            border-radius: 14px;
            margin-bottom: 20px;
            font-size: 14px;
            outline: none;
            transition: 0.3s;
        }
        button {
            width: 100%;
            background: #007AFF; 
            color: white;
            border: none;
            padding: 16px;
            border-radius: 14px;
            cursor: pointer;
            font-weight: 600;
            font-size: 17px;
            transition: all 0.2s;
            box-shadow: 0 4px 12px rgba(0, 122, 255, 0.3);
        }
        button:hover { 
            background: #0062cc;
            transform: scale(0.98); 
        }
        .tips { 
            margin-top: 20px; 
            font-size: 0.8rem; 
            color: rgba(255, 255, 255, 0.4); 
        }
    </style>
</head>
<body>
    <div class="container">
        <img id="teamLogo" src="https://cdn.nba.com/headshots/nba/latest/1040x760/logoman.png" class="team-logo" alt="Team Logo">
        
        <h1>NBA Live</h1>
        <p>Select a team to start streaming</p>
        
        <div class="select-wrapper">
            <select id="channelSelect" onchange="updateUI()">
                <option value="" disabled selected data-logo="https://cdn.nba.com/headshots/nba/latest/1040x760/logoman.png">✨ 选择球队 / Select Team</option>
                
                {% for channel in channels %}
                <option value="{{ channel.url }}" data-logo="{{ channel.logo }}">{{ channel.name }}</option>
                {% endfor %}
            </select>
        </div>

        <input type="text" id="pathInput" placeholder="Path will appear here..." readonly>
        
        <button onclick="play()">Open Stream</button>

        <div class="tips">
            Safari 浏览器可直接播放<br>Chrome 请复制跳转后的链接
        </div>
    </div>

    <script>
        // 核心逻辑:同时更新输入框和图片
        function updateUI() {
            var select = document.getElementById("channelSelect");
            var input = document.getElementById("pathInput");
            var logo = document.getElementById("teamLogo");
            
            // 1. 更新路径
            input.value = select.value;
            // 2. 获取当前选中项的 logo URL
            var selectedOption = select.options[select.selectedIndex];
            var logoUrl = selectedOption.getAttribute('data-logo');
            // 3. 更新图片并添加动画效果
            if (logoUrl) {
                logo.style.transform = "scale(0.8)"; // 先缩小
                setTimeout(() => {
                    logo.src = logoUrl;
                    logo.style.transform = "scale(1)"; // 再弹回正常
                }, 150);
            }
        }
        function play() {
            let path = document.getElementById('pathInput').value;
            if (!path) return alert("请先选择球队");
            path = path.trim();
            if (!path.startsWith('/')) path = '/' + path;
            window.location.href = window.location.origin + path;
        }
    </script>
</body>
</html>

4. app.py (你的 Flask 代码)

这里提供一个实战案例:一个 M3U8 流媒体直连代理。
它的功能是:代理 m3u8 文件,修复相对路径,但让视频流直接走源站(节省服务器流量,速度更快)。

from flask import Flask, Response, request, render_template
import requests
import urllib.parse
import urllib3

urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

app = Flask(__name__)

# ================= 配置 =================
HIDDEN_UPSTREAM = "https://gg.poocloud.in"

# 🏀 30支球队完整列表 (含官方 Logo)
PLAYLIST = [
    {"name": "Atlanta Hawks (老鹰)", "url": "/atlantahawks/index.m3u8", "logo": "https://a.espncdn.com/i/teamlogos/nba/500/atl.png"},
    {"name": "Boston Celtics (凯尔特人)", "url": "/bostonceltics/index.m3u8", "logo": "https://a.espncdn.com/i/teamlogos/nba/500/bos.png"},
    {"name": "Brooklyn Nets (篮网)", "url": "/brooklynnets/index.m3u8", "logo": "https://a.espncdn.com/i/teamlogos/nba/500/bkn.png"},
    {"name": "Charlotte Hornets (黄蜂)", "url": "/charlottehornets/index.m3u8", "logo": "https://a.espncdn.com/i/teamlogos/nba/500/cha.png"},
    {"name": "Chicago Bulls (公牛)", "url": "/chicagobulls/index.m3u8", "logo": "https://a.espncdn.com/i/teamlogos/nba/500/chi.png"},
    {"name": "Cleveland Cavaliers (骑士)", "url": "/clevelandcavaliers/index.m3u8", "logo": "https://a.espncdn.com/i/teamlogos/nba/500/cle.png"},
    {"name": "Dallas Mavericks (独行侠)", "url": "/dallasmavericks/index.m3u8", "logo": "https://a.espncdn.com/i/teamlogos/nba/500/dal.png"},
    {"name": "Denver Nuggets (掘金)", "url": "/denvernuggets/index.m3u8", "logo": "https://a.espncdn.com/i/teamlogos/nba/500/den.png"},
    {"name": "Detroit Pistons (活塞)", "url": "/detroitpistons/index.m3u8", "logo": "https://a.espncdn.com/i/teamlogos/nba/500/det.png"},
    {"name": "Golden State Warriors (勇士)", "url": "/goldenstatewarriors/index.m3u8", "logo": "https://a.espncdn.com/i/teamlogos/nba/500/gs.png"},
    {"name": "Houston Rockets (火箭)", "url": "/houstonrockets/index.m3u8", "logo": "https://a.espncdn.com/i/teamlogos/nba/500/hou.png"},
    {"name": "Indiana Pacers (步行者)", "url": "/indianapacers/index.m3u8", "logo": "https://a.espncdn.com/i/teamlogos/nba/500/ind.png"},
    {"name": "LA Clippers (快船)", "url": "/laclippers/index.m3u8", "logo": "https://a.espncdn.com/i/teamlogos/nba/500/lac.png"},
    {"name": "Los Angeles Lakers (湖人)", "url": "/lakers/index.m3u8", "logo": "https://a.espncdn.com/i/teamlogos/nba/500/lal.png"},
    {"name": "Memphis Grizzlies (灰熊)", "url": "/memphisgrizzlies/index.m3u8", "logo": "https://a.espncdn.com/i/teamlogos/nba/500/mem.png"},
    {"name": "Miami Heat (热火)", "url": "/miamineheat/index.m3u8", "logo": "https://a.espncdn.com/i/teamlogos/nba/500/mia.png"},
    {"name": "Milwaukee Bucks (雄鹿)", "url": "/milwaukeebucks/index.m3u8", "logo": "https://a.espncdn.com/i/teamlogos/nba/500/mil.png"},
    {"name": "Minnesota Timberwolves (森林狼)", "url": "/minnesotatimberwolves/index.m3u8", "logo": "https://a.espncdn.com/i/teamlogos/nba/500/min.png"},
    {"name": "New Orleans Pelicans (鹈鹕)", "url": "/neworleanspelicans/index.m3u8", "logo": "https://a.espncdn.com/i/teamlogos/nba/500/no.png"},
    {"name": "New York Knicks (尼克斯)", "url": "/newyorkknicks/index.m3u8", "logo": "https://a.espncdn.com/i/teamlogos/nba/500/ny.png"},
    {"name": "Oklahoma City Thunder (雷霆)", "url": "/oklahomacitythunder/index.m3u8", "logo": "https://a.espncdn.com/i/teamlogos/nba/500/okc.png"},
    {"name": "Orlando Magic (魔术)", "url": "/orlandomagic/index.m3u8", "logo": "https://a.espncdn.com/i/teamlogos/nba/500/orl.png"},
    {"name": "Philadelphia 76ers (76人)", "url": "/philadelphia76ers/index.m3u8", "logo": "https://a.espncdn.com/i/teamlogos/nba/500/phi.png"},
    {"name": "Phoenix Suns (太阳)", "url": "/phoenixsuns/index.m3u8", "logo": "https://a.espncdn.com/i/teamlogos/nba/500/phx.png"},
    {"name": "Portland Trail Blazers (开拓者)", "url": "/portlandtrailblazers/index.m3u8", "logo": "https://a.espncdn.com/i/teamlogos/nba/500/por.png"},
    {"name": "Sacramento Kings (国王)", "url": "/sacramentokings/index.m3u8", "logo": "https://a.espncdn.com/i/teamlogos/nba/500/sac.png"},
    {"name": "San Antonio Spurs (马刺)", "url": "/sanantoniospurs/index.m3u8", "logo": "https://a.espncdn.com/i/teamlogos/nba/500/sas.png"},
    {"name": "Toronto Raptors (猛龙)", "url": "/torontoraptors/index.m3u8", "logo": "https://a.espncdn.com/i/teamlogos/nba/500/tor.png"},
    {"name": "Utah Jazz (爵士)", "url": "/utahjazz/index.m3u8", "logo": "https://a.espncdn.com/i/teamlogos/nba/500/utah.png"},
    {"name": "Washington Wizards (奇才)", "url": "/washingtonwizards/index.m3u8", "logo": "https://a.espncdn.com/i/teamlogos/nba/500/was.png"}
]

HEADERS = {
    "Referer": "https://embedsports.top/",
    "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0 Safari/537.36"
}
# =======================================

@app.route('/')
def home():
    return render_template('index.html', channels=PLAYLIST)

@app.route('/<path:subpath>')
def direct_proxy(subpath):
    real_url = f"{HIDDEN_UPSTREAM}/{subpath}"

    if "index.m3u8" in subpath:
        real_url = real_url.replace("index.m3u8", "tracks-v1a1/mono.ts.m3u8")

    if request.query_string:
        real_url += f"?{request.query_string.decode('utf-8')}"

    print(f"[HFS Mode] Processing: {real_url}")

    try:
        r = requests.get(real_url, headers=HEADERS, timeout=10, verify=False)

        if "application/vnd.apple.mpegurl" in r.headers.get("Content-Type", "") or subpath.endswith(".m3u8"):
            base_url = real_url
            content = r.text
            new_lines = []

            for line in content.splitlines():
                line = line.strip()
                if not line or line.startswith("#"):
                    new_lines.append(line)
                    continue
                
                absolute_url = urllib.parse.urljoin(base_url, line)
                new_lines.append(absolute_url)

            return Response("\n".join(new_lines), status=r.status_code, mimetype="application/vnd.apple.mpegurl")

        return Response(r.content, status=r.status_code, content_type=r.headers.get("Content-Type"))

    except Exception as e:
        return f"Proxy Error: {e}", 500

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=7860)

⚙️ 第三步:设置公开 (Public) —— 最容易被坑的一步!

如果你发现代码部署成功了,Chrome 能访问,但是 Safari 或播放器报 404,绝对是因为这里没设置!

  1. 点击 Space 上方的 Settings 标签。
  2. 向下滚动找到 Change Space Visibility
  3. 将状态从 Private 改为 Public

原理

  • Private: 只有登录了 HF 账号的浏览器才能访问(类似于开了登录墙)。播放器无法登录,所以会被拦截。
  • Public: 任何人都可以访问,这样播放器才能直接连接 API。

🚀 第四步:构建与测试

  1. 文件上传完毕后,点击 App 标签。
  2. 你会看到状态从 Building 变为 Running(通常需要 1-3 分钟)。
  3. 当看到 Running 时,你的服务就上线了!

你的访问地址格式为:
https://你的用户名-空间名.hf.space

例如:https://cody-flask.hf.space/nba/index.m3u8


❓ 常见问题 (FAQ)

Q1: 为什么我的 Docker 构建失败?

检查 Dockerfile 文件名是否正确(首字母 D 大写),检查 requirements.txt 里的库名是否拼写正确。

Q2: 为什么日志里显示 Running,但我访问网页是 502 Bad Gateway?

99% 是因为你的 app.py 端口没写对。HF 强制要求监听 7860。请检查 app.run(host='0.0.0.0', port=7860)

Q3: 我能部署多个项目吗?

可以!直接再去 New Space 创建一个新的即可。每个 Space 都是独立的容器,互不干扰。

Q4: 这种方式适合生产环境吗?

不适合。它是免费的共享资源,虽然稳定但不能保证 100% SLA。适合个人学习、测试、自用工具或小规模分享。


结语

通过 Hugging Face Spaces + Docker,我们成功白嫖了一个高可用、支持 HTTPS 的 Python 运行环境。无需维护服务器,不用担心账单,这绝对是目前最香的轻量级部署方案。

如果你觉得这篇文章有用,欢迎分享给更多需要的朋友!🌟

文章作者: I-Meet
本文链接:
版权声明: 本站所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 I-Meet
科技
喜欢就支持一下吧