从文件上传、流式下载到预签名 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
- 记录审计日志
- 将对象存储异常翻译成业务错误
完整请求链路可以表示为:
这里通常会并存两条数据路径。
- 服务端代理模式,文件经过 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-datapydantic-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 Content、Content-Range 和 Accept-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。
其过程可以理解为:
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:
- 创建 Multipart Upload
- 为每个
UploadPart生成预签名 URL - 客户端并行上传分片
- 客户端收集各分片的
ETag - FastAPI 验证后完成合并
- 超时或取消时执行
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 |
403 或 502 |
区分用户无权与服务凭据错误 |
NoSuchBucket |
500 |
部署或初始化错误 |
SignatureDoesNotMatch |
502 |
检查时钟、Region、路径和请求头 |
| 连接超时 | 503 |
重试并触发告警 |
| 读取超时 | 504 |
检查网络与超时配置 |
| 存储空间不足 | 507 或 503 |
告警并停止继续写入 |
统一封装示例:
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、数据库、对象存储和入口网关分开管理。
在这个结构中:
- 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 代理返回
十二、推荐的落地顺序
一套较稳的实施路线,不必一口气把所有高级能力塞进第一版。
- 建立独立
storage模块,完成put/get/head/delete - 使用固定测试 Bucket 验证 RustFS 连接和 SigV4
- 实现 FastAPI 代理上传与流式下载
- 将对象键、文件名、用户权限保存到数据库
- 加入统一异常映射、超时、连接池和重试
- 为大文件增加预签名上传和下载
- 加入
HeadObject确认与上传状态机 - 增加 Multipart、断点续传和废弃分片清理
- 加入病毒扫描、审计、指标和告警
- 使用兼容性测试验证 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 , StreamingResponse、FileResponse 等响应类型说明。
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 ,项目源码、许可证、架构及部署文件。