FastAPI 与 RustFS 集成指南

从文件上传、流式下载到预签名 URL 与生产级架构

FastAPI 与 RustFS 的集成,本质上是让 FastAPI 充当业务入口,通过 Amazon S3 兼容协议访问 RustFS。RustFS 负责保存对象,FastAPI 则处理用户身份、业务权限、文件元数据和访问策略。

这层 S3 兼容性很关键。它意味着应用通常不需要使用 RustFS 专属 Python SDK,而是可以直接采用 AWS 官方 Python SDK------Boto3。将客户端的 endpoint_url 指向 RustFS 后,同一套代码原则上也能接入 MinIO、Ceph RGW、SeaweedFS S3 Gateway、Garage,以及公有云对象存储。真正需要谨慎处理的,不是简单的上传和下载,而是异步阻塞、内存占用、连接池、对象键设计、预签名 URL、权限隔离,以及不同 S3 实现之间并非百分之百一致的边缘行为。


一、先把几个核心概念讲清楚

理解下面这些概念后,代码就不再像一团 SDK 咒语,而会变成一套相当清楚的网络交互。

1. RustFS 是什么

RustFS 是使用 Rust 构建的开源分布式对象存储系统,对外提供 S3 兼容接口。应用看到的不是磁盘目录,而是一组通过 HTTP API 管理的对象。

对象存储通常包含三个层次:

  • 存储服务,例如 RustFS 集群
  • Bucket,可理解为顶层逻辑容器
  • Object ,真正保存的数据,由对象键 key 唯一标识

例如:

text 复制代码
Bucket: research-files
Key: users/42/projects/alpha/0192f7/report.pdf

这里的 users/42/projects/alpha/0192f7/report.pdf 看起来像路径,但它通常只是一个字符串键。对象存储可以依据斜杠模拟目录结构,却不等于传统文件系统目录。

RustFS 宣称兼容 S3,因此 Python 侧可以使用 Boto3,通过自定义端点连接 RustFS。AWS 凭据、请求签名、Bucket、对象键、多段上传和预签名 URL 等概念也随之沿用。

2. S3 兼容究竟意味着什么

S3 不只是一个产品,也逐渐成了对象存储领域事实上的 API 标准。常见操作包括:

S3 操作 作用 典型业务场景
PutObject 上传对象 图片、附件、小型文档
GetObject 获取对象 下载、预览、模型加载
HeadObject 只查询元数据 判断对象是否存在
DeleteObject 删除对象 用户删除附件
ListObjectsV2 按前缀列举对象 管理后台、批处理
Multipart Upload 分段上传 大文件、弱网络环境
Presigned URL 临时授权访问 浏览器直传、临时下载

所谓兼容,通常是指核心请求格式、签名方式和主要 API 与 Amazon S3 接近。它不保证所有高级功能和细节都完全相同,比如对象锁、事件通知、生命周期策略、复制、条件请求和虚拟主机寻址等能力,仍应针对目标版本做兼容性测试。

工程上最好把对象存储封装在独立模块里,避免业务代码到处直接调用 Boto3。这样从 MinIO 切换到 RustFS,或者从 RustFS 切换到其他 S3 实现时,修改面会小得多。

3. FastAPI 在架构中的位置

FastAPI 不应被当成对象存储本身。它更适合承担这些职责:

  • 验证 JWT、Cookie 或 API Key
  • 判断用户能否上传、读取或删除某个对象
  • 生成安全的对象键
  • 校验文件类型和大小
  • 保存业务元数据
  • 生成预签名 URL
  • 记录审计日志
  • 将对象存储异常翻译成业务错误

完整请求链路可以表示为:

flowchart LR U[浏览器或客户端] --> A[FastAPI] A --> P[鉴权与业务权限] P --> D[(关系数据库)] P --> S[RustFS S3 API] S --> V[(磁盘或分布式存储卷)] A -.生成预签名 URL.-> U U -.直接上传或下载.-> S

这里通常会并存两条数据路径。

  • 服务端代理模式,文件经过 FastAPI,再写入 RustFS
  • 客户端直传模式,FastAPI只签发临时 URL,文件直接进入 RustFS

小规模系统采用代理模式很省心。大文件、高并发场景更适合直传,否则 FastAPI 会沦为昂贵的数据搬运工------忙得满头大汗,却没增加多少业务价值。


二、连接 RustFS 的基础配置

RustFS 官方 Python 指南推荐使用 Boto3,并将 S3 客户端指向 RustFS 自定义端点。Boto3 自身是同步客户端,因此 FastAPI 中还要额外处理阻塞调用。

1. 安装依赖

bash 复制代码
pip install fastapi uvicorn[standard] boto3 \
    python-multipart pydantic-settings

其中:

  • boto3 提供 S3 客户端
  • python-multipart 让 FastAPI 解析 multipart/form-data
  • pydantic-settings 管理环境变量
  • uvicorn 运行 ASGI 应用

FastAPI 的 UploadFile 适合接收上传文件。它背后使用类似临时缓冲文件的机制,小文件可以保存在内存中,超过阈值后落到临时磁盘,通常比把整个请求体声明成 bytes 更适合大文件。

2. 环境变量

dotenv 复制代码
S3_ENDPOINT_URL=http://rustfs:9000
S3_ACCESS_KEY=change-me
S3_SECRET_KEY=change-me-too
S3_REGION=us-east-1
S3_BUCKET=research-files
S3_USE_SSL=false
S3_VERIFY_SSL=false
S3_ADDRESSING_STYLE=path

生产环境不要把密钥直接写入 Git 仓库。更稳妥的来源包括:

  • Kubernetes Secret
  • Docker Secret
  • Vault
  • 云平台密钥管理服务
  • 受权限保护的环境变量文件

S3_VERIFY_SSL=false 只适合本地开发。如果生产环境使用 HTTPS,应配置可信证书,并开启证书校验。

3. 创建可复用客户端

python 复制代码
# app/storage.py

from functools import lru_cache
from typing import BinaryIO, Any

import boto3
from botocore.client import BaseClient
from botocore.config import Config
from boto3.s3.transfer import TransferConfig
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    s3_endpoint_url: str = "http://localhost:9000"
    s3_access_key: str
    s3_secret_key: str
    s3_region: str = "us-east-1"
    s3_bucket: str = "research-files"
    s3_use_ssl: bool = False
    s3_verify_ssl: bool = True
    s3_addressing_style: str = "path"

    model_config = SettingsConfigDict(
        env_file=".env",
        case_sensitive=False,
    )


@lru_cache
def get_settings() -> Settings:
    return Settings()


@lru_cache
def get_s3_client() -> BaseClient:
    settings = get_settings()

    return boto3.client(
        "s3",
        endpoint_url=settings.s3_endpoint_url,
        aws_access_key_id=settings.s3_access_key,
        aws_secret_access_key=settings.s3_secret_key,
        region_name=settings.s3_region,
        use_ssl=settings.s3_use_ssl,
        verify=settings.s3_verify_ssl,
        config=Config(
            signature_version="s3v4",
            max_pool_connections=50,
            connect_timeout=5,
            read_timeout=120,
            retries={
                "max_attempts": 4,
                "mode": "standard",
            },
            s3={
                "addressing_style": settings.s3_addressing_style,
            },
        ),
    )


TRANSFER_CONFIG = TransferConfig(
    multipart_threshold=16 * 1024 * 1024,
    multipart_chunksize=16 * 1024 * 1024,
    max_concurrency=8,
    use_threads=True,
)


def upload_fileobj(
    fileobj: BinaryIO,
    key: str,
    content_type: str,
    metadata: dict[str, str] | None = None,
) -> None:
    settings = get_settings()
    client = get_s3_client()

    client.upload_fileobj(
        Fileobj=fileobj,
        Bucket=settings.s3_bucket,
        Key=key,
        ExtraArgs={
            "ContentType": content_type,
            "Metadata": metadata or {},
        },
        Config=TRANSFER_CONFIG,
    )


def get_object(key: str) -> dict[str, Any]:
    settings = get_settings()
    return get_s3_client().get_object(
        Bucket=settings.s3_bucket,
        Key=key,
    )


def delete_object(key: str) -> None:
    settings = get_settings()
    get_s3_client().delete_object(
        Bucket=settings.s3_bucket,
        Key=key,
    )

这里有几个容易被忽略的参数。

endpoint_url

它把原本发送到 AWS 的请求改为发送到 RustFS,例如:

text 复制代码
http://rustfs:9000

容器之间通信时不要写 localhost,因为容器内的 localhost 指向容器自己。应使用 Compose 服务名、Kubernetes Service 名称或内部域名。

signature_version

推荐使用 s3v4,也就是 AWS Signature Version 4。它会把请求方法、路径、查询参数、请求头、负载摘要和时间信息组合起来,再用密钥计算签名。服务端重新计算一次,结果一致才接受请求。

签名既能证明调用方掌握密钥,也能发现请求在传输过程中是否被篡改。它并不替代 HTTPS,签名负责身份和完整性,TLS 负责传输机密性,两者不是同一个东西。

addressing_style

S3 常见两种寻址形式。

路径式寻址

text 复制代码
https://storage.example.com/research-files/report.pdf

虚拟主机式寻址

text 复制代码
https://research-files.storage.example.com/report.pdf

本地开发通常用 path 更简单,因为虚拟主机式需要通配符 DNS 和相匹配的 TLS 证书。生产部署若使用 virtual,需要保证 Bucket 子域名能正确解析到 RustFS。

连接池和超时

Botocore 默认连接池规模有限。FastAPI 有较高并发时,max_pool_connections 应与实际线程并发量协调设置,而不是盲目调到几千。

  • 连接池太小,请求会等待可用连接
  • 连接池太大,会消耗文件描述符和服务端连接资源
  • 读取超时太短,大文件下载容易中断
  • 超时无限放宽,又可能让故障连接长期占用资源

Botocore 的 Config 支持连接超时、读取超时、连接池、签名版本、代理和重试等参数,是生产配置的核心入口。


三、代理上传,让文件经过 FastAPI

代理上传的优点是控制力强。FastAPI 能在数据写入 RustFS 前执行鉴权、格式检查、病毒扫描或内容解析。

1. 基础上传接口

python 复制代码
# app/main.py

from pathlib import Path
from uuid import uuid4

from botocore.exceptions import ClientError, BotoCoreError
from fastapi import FastAPI, File, HTTPException, UploadFile, status
from starlette.concurrency import run_in_threadpool

from app.storage import upload_fileobj


app = FastAPI(title="FastAPI + RustFS")


ALLOWED_CONTENT_TYPES = {
    "application/pdf",
    "image/jpeg",
    "image/png",
    "text/plain",
}

MAX_FILE_SIZE = 50 * 1024 * 1024


def make_object_key(user_id: str, filename: str | None) -> str:
    suffix = Path(filename or "").suffix.lower()
    return f"users/{user_id}/{uuid4().hex}{suffix}"


@app.post("/files", status_code=status.HTTP_201_CREATED)
async def upload_file(
    file: UploadFile = File(...),
):
    user_id = "42"

    if file.content_type not in ALLOWED_CONTENT_TYPES:
        raise HTTPException(
            status_code=415,
            detail="不支持这种文件类型",
        )

    await file.seek(0, 2)
    size = file.file.tell()
    await file.seek(0)

    if size > MAX_FILE_SIZE:
        raise HTTPException(
            status_code=413,
            detail="文件超过大小限制",
        )

    key = make_object_key(user_id, file.filename)

    try:
        await run_in_threadpool(
            upload_fileobj,
            file.file,
            key,
            file.content_type or "application/octet-stream",
            {
                "owner-id": user_id,
                "original-name": file.filename or "unnamed",
            },
        )
    except (ClientError, BotoCoreError) as exc:
        raise HTTPException(
            status_code=502,
            detail="对象存储暂时不可用",
        ) from exc
    finally:
        await file.close()

    return {
        "key": key,
        "filename": file.filename,
        "content_type": file.content_type,
        "size": size,
    }

2. 为什么要使用线程池

FastAPI 路由可以写成 async def,但这并不会自动把内部所有函数变成异步函数。Boto3 使用同步网络 I/O,调用期间会阻塞当前线程。

如果在事件循环线程中直接执行:

python 复制代码
client.put_object(...)

事件循环就无法及时处理其他请求。并发一高,延迟会明显恶化。

因此可以用:

python 复制代码
await run_in_threadpool(sync_function, ...)

把同步 Boto3 调用交给线程池执行。另一种做法是使用基于异步 HTTP 客户端的 S3 封装,但这会引入不同的版本兼容和维护成本。对于多数业务系统,Boto3 加线程池更成熟,也更容易排查问题。

线程池不是无限资源。高并发大文件上传时,依然推荐预签名直传,不要指望给服务器多开几百个线程就能解决一切。那通常只会让问题穿上一件更昂贵的外套。

3. 不要信任原始文件名

客户端提供的文件名不能直接作为对象键,原因包括:

  • 可能包含 ../
  • 不同用户可能上传同名文件
  • Unicode 规范化可能造成混淆
  • 文件名可能含控制字符
  • URL 编码和代理转发可能产生歧义
  • 文件名本身可能泄露敏感信息

更安全的方式是:

  • 对象键使用 UUID、ULID 或数据库主键
  • 原始文件名保存在数据库或对象元数据中
  • 下载时通过 Content-Disposition 恢复友好名称
  • 对扩展名进行白名单清洗,而非直接拼接

4. 文件大小校验的边界

示例通过 seek 获取上传文件大小。这适用于 FastAPI 已经接收并缓冲的文件,但它不能阻止超大请求先进入应用。

生产环境应在多层设限:

  • 反向代理限制请求体大小
  • ASGI 层设置合理限制
  • FastAPI 执行业务限制
  • 对象存储或预签名策略限制大小
  • 浏览器端提前提示,但不能把前端校验当成安全措施

对极大文件,应采用客户端分片上传或预签名直传,而不是让 FastAPI 先完整接收,再告诉用户文件太大。


四、流式下载,避免把整个对象读入内存

get_object 返回的 Body 是流式响应对象。正确做法是分块读取,而不是一次调用 read() 把几 GB 文件塞进内存。

python 复制代码
from urllib.parse import quote

from botocore.exceptions import ClientError
from fastapi import HTTPException
from fastapi.responses import StreamingResponse
from starlette.concurrency import run_in_threadpool

from app.storage import get_object


def iter_s3_body(body, chunk_size: int = 1024 * 1024):
    try:
        while True:
            chunk = body.read(chunk_size)
            if not chunk:
                break
            yield chunk
    finally:
        body.close()


@app.get("/files/{key:path}")
async def download_file(key: str):
    # 真实项目必须先检查当前用户是否有权访问这个 key
    try:
        obj = await run_in_threadpool(get_object, key)
    except ClientError as exc:
        code = exc.response.get("Error", {}).get("Code")

        if code in {"NoSuchKey", "404", "NotFound"}:
            raise HTTPException(404, "文件不存在") from exc

        raise HTTPException(502, "对象存储读取失败") from exc

    filename = key.rsplit("/", 1)[-1]
    encoded_filename = quote(filename)

    headers = {
        "Content-Disposition":
            f"attachment; filename*=UTF-8''{encoded_filename}",
    }

    if obj.get("ContentLength") is not None:
        headers["Content-Length"] = str(obj["ContentLength"])

    if obj.get("ETag"):
        headers["ETag"] = obj["ETag"]

    if obj.get("LastModified"):
        headers["Last-Modified"] = obj["LastModified"].strftime(
            "%a, %d %b %Y %H:%M:%S GMT"
        )

    return StreamingResponse(
        iter_s3_body(obj["Body"]),
        media_type=obj.get(
            "ContentType",
            "application/octet-stream",
        ),
        headers=headers,
    )

StreamingResponse 会逐块把内容发送给客户端,能显著降低单次请求的峰值内存。不过,这种方式仍会占用 FastAPI 到 RustFS、FastAPI 到客户端的两段带宽。

还要留心一个细节。生成器中的 body.read() 是同步读取。常规下载量下通常可以接受,但在大规模并发下载场景中,推荐直接返回预签名 URL,或者由 Nginx、网关及专门的数据服务承接下载。

如果业务要求视频在线播放、断点续传或 PDF 按需加载,还应转发客户端的 Range 请求,并正确返回 206 Partial ContentContent-RangeAccept-Ranges。简单地忽略 Range 会导致客户端每次都从头下载。FastAPI 提供 StreamingResponse 等自定义响应类型,可用于流式发送生成器或迭代器产生的数据。


五、预签名 URL,生产系统更常见的方案

预签名 URL 是一段带有临时签名的地址。用户不需要知道 RustFS 的访问密钥,只要在过期时间内使用该 URL,就能执行被授权的特定操作。

它的权限范围通常由这些因素共同限定:

  • HTTP 方法
  • Bucket
  • 对象键
  • 有效期
  • 签名凭据对应的权限
  • 部分请求头和查询参数

1. 生成下载 URL

python 复制代码
from fastapi import HTTPException
from starlette.concurrency import run_in_threadpool

from app.storage import get_s3_client, get_settings


def create_download_url(key: str, expires: int = 600) -> str:
    settings = get_settings()

    return get_s3_client().generate_presigned_url(
        ClientMethod="get_object",
        Params={
            "Bucket": settings.s3_bucket,
            "Key": key,
        },
        ExpiresIn=expires,
    )


@app.post("/files/download-url")
async def issue_download_url(payload: dict):
    key = payload["key"]

    # 此处查询数据库,检查当前用户对 key 的读取权限
    try:
        url = await run_in_threadpool(
            create_download_url,
            key,
            600,
        )
    except Exception as exc:
        raise HTTPException(
            502,
            "无法生成下载地址",
        ) from exc

    return {
        "url": url,
        "expires_in": 600,
    }

2. 生成上传 URL

python 复制代码
def create_upload_url(
    key: str,
    content_type: str,
    expires: int = 600,
) -> str:
    settings = get_settings()

    return get_s3_client().generate_presigned_url(
        ClientMethod="put_object",
        Params={
            "Bucket": settings.s3_bucket,
            "Key": key,
            "ContentType": content_type,
        },
        ExpiresIn=expires,
    )


@app.post("/files/upload-url")
async def issue_upload_url(payload: dict):
    user_id = "42"
    filename = payload["filename"]
    content_type = payload["content_type"]

    if content_type not in ALLOWED_CONTENT_TYPES:
        raise HTTPException(415, "不支持这种文件类型")

    key = make_object_key(user_id, filename)

    url = await run_in_threadpool(
        create_upload_url,
        key,
        content_type,
        600,
    )

    return {
        "key": key,
        "method": "PUT",
        "url": url,
        "headers": {
            "Content-Type": content_type,
        },
        "expires_in": 600,
    }

浏览器收到 URL 后可以直接上传:

javascript 复制代码
await fetch(uploadInfo.url, {
  method: "PUT",
  headers: {
    "Content-Type": file.type
  },
  body: file
});

如果签名时包含了 ContentType,上传时应发送一致的 Content-Type。签名参数和真实请求不一致,常见结果就是 SignatureDoesNotMatch

预签名 URL 由拥有相应权限的凭据生成,使用者能够在有效期内借用这份权限,但不会获得原始密钥。AWS 的 Boto3 文档也建议使用 Signature Version 4,并明确指出预签名 URL 只能在指定期限内使用。

3. 为什么直传更适合高并发

假设一个 2 GB 文件经过 FastAPI 代理上传,数据路径是:

text 复制代码
客户端 → FastAPI → RustFS

FastAPI 至少要接收一次,再发送一次。若使用直传:

text 复制代码
客户端 → RustFS

FastAPI只处理一个很小的签名请求。由此带来的收益很直接:

  • 减少应用服务器带宽
  • 降低线程和连接占用
  • 避免临时文件挤满磁盘
  • 减少应用扩容压力
  • 上传速度更接近存储服务上限
  • 文件流量与业务 API 流量可以分别治理

代价是浏览器需要能访问 RustFS 对外端点,并正确配置 CORS、TLS、DNS 和网络策略。

4. CORS 配置的意义

浏览器从 https://app.example.com 直接访问 https://storage.example.com,属于跨源请求。RustFS 需要允许指定来源执行所需方法。

概念上通常要允许:

text 复制代码
Origin:
  https://app.example.com

Methods:
  PUT
  GET
  HEAD

Headers:
  Content-Type
  x-amz-*

不要在生产环境随手放开所有来源、方法和请求头。预签名 URL 已经是一种临时能力凭证,再叠加过宽的 CORS,会扩大误用空间。

CORS 只约束浏览器,不是服务端鉴权机制。攻击者完全可以绕过浏览器,直接用脚本调用 URL。因此真正的安全边界仍然是签名、有效期、对象键和后台权限判断。


六、大文件与 Multipart Upload

单次 PutObject 适合普通文件。文件很大、网络不稳定或需要断点续传时,应使用 Multipart Upload。

其过程可以理解为:

sequenceDiagram participant C as 客户端 participant A as FastAPI participant R as RustFS C->>A: 请求开始分片上传 A->>R: CreateMultipartUpload R-->>A: upload_id A-->>C: upload_id 与分片上传信息 loop 每个分片 C->>R: UploadPart R-->>C: ETag end C->>A: 提交分片编号与 ETag A->>R: CompleteMultipartUpload R-->>A: 对象创建完成 A-->>C: 返回业务文件记录

Boto3 自动分片

前面的 upload_fileobj 配合 TransferConfig,可以在对象超过阈值后自动使用分段传输:

python 复制代码
TRANSFER_CONFIG = TransferConfig(
    multipart_threshold=16 * 1024 * 1024,
    multipart_chunksize=16 * 1024 * 1024,
    max_concurrency=8,
)

参数含义如下:

  • multipart_threshold,超过这个大小后启动多段上传
  • multipart_chunksize,每段的目标大小
  • max_concurrency,并发传输数量
  • use_threads,是否使用线程并发传输

分段越小,请求数量越多;分段越大,失败重传成本越高。并发越高也不一定越快,它还受到客户端带宽、RustFS 节点能力、磁盘吞吐和连接池大小限制。Boto3 的传输管理器支持自动选择单次或 Multipart 上传,并允许调整分段阈值、分段大小和并发数。

浏览器分片直传

更大规模的系统可以让 FastAPI:

  1. 创建 Multipart Upload
  2. 为每个 UploadPart 生成预签名 URL
  3. 客户端并行上传分片
  4. 客户端收集各分片的 ETag
  5. FastAPI 验证后完成合并
  6. 超时或取消时执行 AbortMultipartUpload

这里必须保存 upload_id、对象键、上传用户和分片状态。未完成的 Multipart Upload 可能长期占用存储空间,应配置清理任务或生命周期策略。


七、元数据应该放在哪里

对象存储可以保存自定义元数据,但它并不适合替代业务数据库。

推荐把数据分成两类。

RustFS 中保存

  • 文件二进制内容
  • Content-Type
  • 缓存控制头
  • 简短、稳定的对象元数据
  • 校验和或加密相关信息

数据库中保存

  • 业务文件 ID
  • 所属用户、项目和组织
  • 原始文件名
  • 对象键
  • 文件状态
  • 上传时间
  • 权限模型
  • 软删除状态
  • 病毒扫描结果
  • 解析和转码状态
  • 审计信息

一个实用的数据表可以这样设计:

sql 复制代码
CREATE TABLE stored_file (
    id UUID PRIMARY KEY,
    bucket VARCHAR(128) NOT NULL,
    object_key TEXT NOT NULL UNIQUE,
    original_name TEXT NOT NULL,
    content_type VARCHAR(255),
    size_bytes BIGINT,
    etag VARCHAR(255),
    owner_id UUID NOT NULL,
    status VARCHAR(32) NOT NULL,
    created_at TIMESTAMPTZ NOT NULL,
    deleted_at TIMESTAMPTZ
);

推荐使用状态机管理上传过程:

text 复制代码
PENDING → UPLOADING → AVAILABLE
                  ↘ FAILED
AVAILABLE → DELETING → DELETED

采用预签名直传后,不能因为 FastAPI 成功生成 URL,就把文件标为 AVAILABLE。客户端可能拿到 URL 后什么也没做。更稳妥的办法是上传完成后调用确认接口,由 FastAPI 执行 HeadObject,核对对象是否存在、大小是否符合预期,再更新数据库状态。


八、异常处理与兼容性边界

对象存储返回的错误通常包含 HTTP 状态码和 S3 错误码。业务 API 不宜把底层异常原样暴露给客户端。

可以建立统一映射:

存储异常 FastAPI 响应 处理建议
NoSuchKey 404 文件不存在
AccessDenied 403502 区分用户无权与服务凭据错误
NoSuchBucket 500 部署或初始化错误
SignatureDoesNotMatch 502 检查时钟、Region、路径和请求头
连接超时 503 重试并触发告警
读取超时 504 检查网络与超时配置
存储空间不足 507503 告警并停止继续写入

统一封装示例:

python 复制代码
from botocore.exceptions import (
    BotoCoreError,
    ClientError,
    ConnectTimeoutError,
    ReadTimeoutError,
)
from fastapi import HTTPException


def translate_storage_error(exc: Exception) -> HTTPException:
    if isinstance(exc, ClientError):
        code = exc.response.get("Error", {}).get("Code", "")

        if code in {"NoSuchKey", "NotFound", "404"}:
            return HTTPException(404, "文件不存在")

        if code in {"AccessDenied", "InvalidAccessKeyId"}:
            return HTTPException(502, "对象存储授权失败")

        if code == "SignatureDoesNotMatch":
            return HTTPException(502, "对象存储签名校验失败")

    if isinstance(
        exc,
        (ConnectTimeoutError, ReadTimeoutError),
    ):
        return HTTPException(504, "对象存储响应超时")

    if isinstance(exc, BotoCoreError):
        return HTTPException(503, "对象存储暂时不可用")

    return HTTPException(500, "文件服务发生未知错误")

常见的 SignatureDoesNotMatch

这种错误经常不是密钥错了,而是以下因素不一致:

  • 客户端和服务端系统时间偏差过大
  • 签名使用的 Region 不一致
  • 代理修改了 Host
  • HTTP 与 HTTPS 配置不一致
  • 路径式与虚拟主机式寻址不一致
  • 签名时包含的 Content-Type 与上传时不同
  • 反向代理对路径进行了重写
  • 对象键经过了二次 URL 编码
  • 预签名 URL 对外暴露的域名与生成时使用的域名不同

尤其要小心内部地址生成的预签名 URL:

text 复制代码
http://rustfs:9000/bucket/key

这个地址在 Docker 网络内部有效,用户浏览器却无法解析 rustfs。生成预签名 URL 时使用的端点应当是客户端真正能够访问的外部地址,或者由网关提供稳定域名,并确保转发过程中不破坏签名所依赖的信息。Signature Version 4 的验证依赖规范化后的方法、路径、查询参数、请求头和时间等内容,代理层的细微改写也可能导致验证失败。


九、安全设计,密钥不是唯一要守住的东西

1. 最小权限

FastAPI 使用的服务账号不应拥有整个 RustFS 集群的管理员权限。应尽可能限制到:

  • 指定 Bucket
  • 指定对象前缀
  • 必要的读、写、删除操作
  • 必要时只允许生成某类临时访问

例如,普通上传服务可能需要:

text 复制代码
PutObject
GetObject
HeadObject
DeleteObject
MultipartUpload 相关操作

它通常不需要创建 Bucket、修改集群配置或管理其他用户。

2. 应用权限不能只靠对象键

不要认为用户知道对象键就有权限访问,也不要认为对象键足够随机就可以替代鉴权。

每次生成下载 URL、代理下载或删除对象之前,应查询数据库确认:

  • 当前用户是谁
  • 文件属于哪个用户或组织
  • 文件是否被删除或冻结
  • 用户当前是否具有读取或删除权限
  • 此次操作是否需要审计

随机对象键能降低猜测风险,却不是权限系统。

3. 文件类型校验

UploadFile.content_type 来自客户端声明,可以伪造。安全级别较高的系统还应检查:

  • 文件魔数
  • 文件扩展名与真实格式是否一致
  • 压缩包递归深度
  • 解压后的总体积
  • 恶意宏和脚本
  • 病毒或木马
  • 图像解析漏洞
  • PDF 和 Office 文档中的主动内容

上传后可以先进入隔离前缀:

text 复制代码
quarantine/{upload_id}

扫描通过后,再复制或移动到正式前缀:

text 复制代码
users/{user_id}/files/{file_id}

4. 加密与 TLS

至少应做到:

  • 客户端到 RustFS 使用 HTTPS
  • FastAPI 到 RustFS 使用 HTTPS
  • 正确验证服务器证书
  • 密钥定期轮换
  • 存储卷启用静态加密或磁盘加密
  • 日志中不打印密钥和完整预签名 URL

预签名 URL 中携带临时签名参数。把完整 URL 写进访问日志、异常监控或聊天系统,相当于在有效期内传播了一张临时门票。


十、生产部署结构

比较稳妥的部署方式,是将 API、数据库、对象存储和入口网关分开管理。

flowchart TB Internet[用户与客户端] Gateway[Ingress / Nginx / API Gateway] API1[FastAPI Worker 1] API2[FastAPI Worker 2] DB[(PostgreSQL)] Queue[任务队列] Worker[扫描与转码 Worker] RustFS[RustFS 集群] Metrics[Prometheus / 日志平台] Internet --> Gateway Gateway --> API1 Gateway --> API2 Gateway --> RustFS API1 --> DB API2 --> DB API1 --> RustFS API2 --> RustFS API1 --> Queue Queue --> Worker Worker --> RustFS API1 --> Metrics API2 --> Metrics RustFS --> Metrics

在这个结构中:

  • FastAPI Worker 保持无状态
  • 文件正文写入 RustFS
  • 文件业务记录写入 PostgreSQL
  • 病毒扫描、转码、OCR 交给后台任务
  • 网关统一处理 TLS、限流和域名
  • RustFS 数据卷使用持久化存储
  • 监控同时覆盖 API 与对象存储

RustFS 的具体启动参数、环境变量和集群编排方式可能随版本变化,部署时应以所用版本的官方安装文档和示例 Compose、Helm 文件为准,不宜照搬其他 S3 实现的启动参数。RustFS 项目提供容器、云原生、高可用、扩容、可观测性和安全相关文档入口。


十一、如何在 RustFS、MinIO 及其他替代方案之间保持可迁移

最实用的策略,不是追求一句口号式的完全兼容,而是建立清晰的存储适配层和兼容性测试。

1. 定义存储接口

python 复制代码
from typing import BinaryIO, Protocol


class ObjectStorage(Protocol):
    def upload(
        self,
        fileobj: BinaryIO,
        key: str,
        content_type: str,
    ) -> None:
        ...

    def download(self, key: str):
        ...

    def delete(self, key: str) -> None:
        ...

    def exists(self, key: str) -> bool:
        ...

    def presign_get(
        self,
        key: str,
        expires: int,
    ) -> str:
        ...

    def presign_put(
        self,
        key: str,
        content_type: str,
        expires: int,
    ) -> str:
        ...

业务层依赖 ObjectStorage,而不是直接依赖 boto3.client。RustFS、MinIO、Ceph RGW 或 Amazon S3 都可以实现同一接口。

2. 建立兼容性测试

切换对象存储前至少测试:

  • 包含中文、空格和特殊字符的对象键
  • 上传、下载、删除和列举
  • HeadObject
  • 自定义元数据
  • Content-Disposition
  • ETag 行为
  • 单次上传
  • Multipart Upload
  • 中止未完成上传
  • 预签名上传和下载
  • 路径式和虚拟主机式寻址
  • HTTPS 与反向代理
  • Range 下载
  • Bucket Policy 和用户权限
  • 同名覆盖行为
  • 版本控制和生命周期策略
  • 异常码是否符合预期

ETag 尤其不能被简单地永久等同于文件 MD5。单段上传时,它在部分实现中可能看起来像 MD5;使用 Multipart、服务端加密或不同存储实现后,语义可能发生变化。需要可靠完整性校验时,应单独保存 SHA-256 等校验和。

3. 方案选择

模式 优点 局限 适用场景
FastAPI 代理上传 权限和内容控制集中 占用 API 带宽与线程 小文件、低并发、强审查
FastAPI 代理下载 可隐藏存储端点 API 成为数据瓶颈 少量私有文件
预签名直传 性能好、扩展容易 需要 CORS 和外部存储域名 大文件、高并发
预签名下载 减轻 API 压力 URL 在有效期内可转发 临时下载、媒体分发
Multipart 直传 可并发、可续传 状态管理更复杂 超大文件、弱网络
网关统一转发 域名和 TLS 集中 需谨慎保护签名路径 企业内部平台

多数成熟系统会混合使用:

  • 小型头像走 FastAPI 代理
  • 大文件走预签名 Multipart
  • 敏感文档由 FastAPI 做权限判断后生成短期下载 URL
  • 需要审计或动态脱敏的内容继续由 API 代理返回

十二、推荐的落地顺序

一套较稳的实施路线,不必一口气把所有高级能力塞进第一版。

  1. 建立独立 storage 模块,完成 put/get/head/delete
  2. 使用固定测试 Bucket 验证 RustFS 连接和 SigV4
  3. 实现 FastAPI 代理上传与流式下载
  4. 将对象键、文件名、用户权限保存到数据库
  5. 加入统一异常映射、超时、连接池和重试
  6. 为大文件增加预签名上传和下载
  7. 加入 HeadObject 确认与上传状态机
  8. 增加 Multipart、断点续传和废弃分片清理
  9. 加入病毒扫描、审计、指标和告警
  10. 使用兼容性测试验证 RustFS、MinIO 或其他后端

这套设计的核心并不复杂,FastAPI 管业务,RustFS 管对象,Boto3 负责说 S3 这门通用语言。真正决定系统质量的,是边界是否清楚。不要让原始密钥流向客户端,不要让同步 SDK 堵住事件循环,不要让大文件长期穿过 API 服务,也不要把对象存储当成关系数据库。把这些边界守住,RustFS 就能自然地嵌入 Python Web 系统,同时保留向其他 S3 兼容方案迁移的余地。


参考资料

RustFS, Python SDK Guide , 使用 AWS SDK for Python Boto3 连接 RustFS。

docs.rustfs.com/en/develope...

RustFS, boto3 Python Examples , Boto3 与 RustFS 的基础对象操作示例。

docs.rustfs.com/en/develope...

FastAPI, Request Files , UploadFile、文件表单与多文件上传说明。

fastapi.tiangolo.com/tutorial/re...

FastAPI, Custom Response , StreamingResponseFileResponse 等响应类型说明。

fastapi.tiangolo.com/advanced/cu...

AWS Boto3 Documentation, Presigned URLs , S3 临时授权 URL 的生成与使用。

boto3.amazonaws.com/v1/document...

Botocore Documentation, Config Reference , Region、签名版本、超时、连接池、代理与 S3 寻址配置。

botocore.amazonaws.com/v1/document...

AWS S3 API Reference, Authenticating Requests with AWS Signature Version 4 ,SigV4 请求认证机制。

docs.aws.amazon.com/AmazonS3/la...

Boto3 Documentation, S3 File Transfer Configuration ,Multipart 阈值、并发传输和线程配置。

boto3.amazonaws.com/v1/document...

RustFS GitHub Repository, rustfs/rustfs ,项目源码、许可证、架构及部署文件。

github.com/rustfs/rust...

相关推荐
先吃饱再说1 小时前
后端开发绕不开的 SQL:从建表、查询到索引优化,一篇讲透
数据库·后端·sql
用户7813667114451 小时前
Ceph RGW 对象上传完整流程解析:请求解析 → 执行 → 落盘
后端
青 春 记 忆1 小时前
零基础入门python25:新增账目——Decimal、日期和输入校验
python·后端开发
北风toto1 小时前
阿里云 MaxCompute通过python脚本调用odps流程和执行sql
python·阿里云·odps
coderCN1 小时前
HTTP(动静分离)
后端
jj_ccwgw1 小时前
Kafka 集群开机自启动配置指南
后端·kafka
qq_161111271 小时前
Pillow项目深度解析:Python图像处理的终极武器
图像处理·python·pillow·开源库·历史解析
梅孔立1 小时前
Pi-Agent 终极极简配置文档(Java+Vue+Python爬虫 4-5千文件项目)
java·vue.js·python
李可以量化1 小时前
Redis Client 从了解到精通(二)下:String 类型进阶操作全解
redis·git·python·量化交易·qmt