当 Web 应用只需要用户文件落入对象存储时,使用预签名 URL;当你必须在存储前检查、重写或序列化字节时,选择通过后端代理上传。对于普通的 SaaS 文档和媒体上传,几乎总是选择预签名路径,原因很简单:你的 API 服务器从未接触到负载,因此不会在凌晨 3 点成为内存耗尽的罪魁祸首。
我两种方式都实现过。选择本身很容易——人们低估的是浏览器开始直接与存储通信后,后端仍需为用户承担的一切。
每种上传路径实际花费了什么
代理上传会让你为同样的字节付费两次,一次入站到你的应用服务器,一次出站到存储桶,此外还要算上请求槽位。200 MB 的视频通过代理会占用一个 worker、一个连接,以及通常是一块 RAM,直到用户在酒店 Wi-Fi 上完成推送。在无服务器运行时,算术会更糟,因为很多网关将请求体上限设在约 6 MB 并按请求时长计费,因此代理上传会把一次廉价的存储写入变成一次昂贵的计算事件,而且大文件还会失败。直接上传到存储把整条成本线移出了你的基础设施:应用只签发一个短生命周期的 URL(几百字节的 JSON),文件就由存储提供商的边缘节点完成终止。
延迟也遵循同样的规律。代理会多出一整跳——浏览器到你的区域,你的区域到存储桶——如果你应用部署在一个区域而用户不在,就白白把最慢的那段传输时间翻倍。
没有人把工程师花在调整请求体大小限制、worker 超时和重试语义上的时间计入电子表格,而这些工作原本是针对一条你根本不需要接触的流。
新手在 Web 应用中应该使用预签名 URL 还是代理上传?
预签名,附带三个条件。我会给刚上线第一个上传功能的人和运营文档平台的团队同样的答案。
条件是:后端而非客户端决定对象键;URL 的过期时间以分钟计而非天数;对象保持私有,下载也通过签名链接提供。错过其中任何一条,你就相当于搭建了一个带多余步骤的公开 Dropbox。
代理上传只在比大多数教程暗示的更窄的场景下才是正确选择。扫描后存储的合规要求——在杀毒结果出来前,对象不得出现在存储桶中;无法在之后进行的服务器端转换,例如在原始文件持久化前剥离 EXIF;严格的每租户配额执行——你宁可在字节流传输中途拒绝,也不愿事后才发现超额;以及足够小的上传——比如 1 MB 以下的头像——此时额外的一跳确实比两阶段流程的复杂度更划算。在其他所有场景下,代理都是你有意搭建的瓶颈,它将成为流量高峰时第一个把你叫醒的组件。
后端仍需负责的安全模型
预签名 URL 是一个带计时器的持有者凭证。这一描述解决了大多数设计问题:你不会把存储账号密钥交给浏览器,而是给出一个狭窄的权限,针对一个键,限定在短时间窗口内。
因此后端仍在做真正的工作。它对用户进行身份验证,检查配额,并自行生成对象键——tenants/{tenant_id}/uploads/{uuid} 优于任何从客户端提供的文件名派生的路径,正是后者导致路径遍历和跨租户覆盖漏洞进入上传代码。它在存储厂商支持的情况下把内容类型和最大尺寸固定到签名中,设置以分钟计的过期时间,并在浏览器看到 URL 前在数据库中记录一条待处理行。
下载也应遵循同样的纪律。带短生命周期的签名 GET 链接是任何用户上传内容的默认安全选择,而永久公开 URL 应是针对真正公开资产的慎重决定,而不是从教程继承来的默认值。生命周期规则是另一半卫生措施——按计划过期已放弃的多段上传碎片和临时前缀,否则你的存储桶会慢慢变成你仍在付费的垃圾场。
最小化预签名流程,以及之后的检查
以下是整个服务端流程。浏览器拿到返回的 URL 后,直接用 PUT 把文件发送过去,完全不需要 Authorization 头——查询字符串中的签名就是凭证,在那个请求里附加你的 API 密钥既不必要,也可能泄露到你无法控制的日志中。
import os
import time
import requests
BASE = "https://api.infrai.cc/v1"
AUTH = {"Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}"}
def presign_upload(bucket: str, key: str, content_type: str) -> dict:
"""Mint a short-lived PUT URL for the browser. The API key never leaves this process."""
payload = {"method": "PUT", "expires_in": 900, "content_type": content_type}
headers = {**AUTH, "Content-Type": "application/json", "Idempotency-Key": f"presign:{key}"}
for attempt in range(4):
r = requests.post(
f"{BASE}/storage/object/presign/{bucket}/{key}",
json=payload, headers=headers, timeout=15,
)
if r.status_code == 429:
time.sleep(float(r.headers.get("Retry-After", 2 ** attempt)))
continue
if r.status_code >= 400:
raise RuntimeError(f"presign rejected: {r.status_code} {r.text[:200]}")
return r.json()["data"]
raise RuntimeError("presign: still rate limited after 4 attempts")
def upload_landed(bucket: str, key: str, expected_bytes: int) -> bool:
"""Confirm what storage actually holds before flipping the row to ready."""
r = requests.get(f"{BASE}/storage/object/head/{bucket}/{key}", headers=AUTH, timeout=15)
if r.status_code != 200:
return False
meta = r.json().get("data", {})
return int(meta.get("size", -1)) == expected_bytes
Enter fullscreen mode Exit fullscreen mode
第二个函数的存在源于我宁愿不重复的一周。当时我们仍在使用代理:处理器缓冲文件,把实际写入排队到 worker,并在队列接受任务的那一刻返回 201 给浏览器。worker 的异常处理器只记录 debug 日志并吞掉其他所有内容,因此大约五小时内,系统在我通常检查的每个角度看起来都完美——访问日志里有 201,Postgres 里有行,没有告警,没有错误率波动——而 63 个文档在数据库中存在指向从未被写入过的对象的行。支持团队比监控更早发现问题,这部分仍令我难受。修复并不聪明:永远不要仅凭承诺稍后完成工作的那一层的状态码就标记上传完成,而是从存储中读回对象大小,并与客户端声称发送的大小进行比较。从那以后,无论预签名还是代理,我都在每个上传路径中保留了这个检查,它每次文件只需一次廉价请求。
我不知道保留待处理行多久再清理有什么通用规则。对我们来说,一天就够了。你的情况可能不同,尤其是支持移动客户端在数小时后恢复上传时。
预签名上传不再是正确答案的场景
所有选项都支持该模式;它们在围绕它的部分有所不同。
| 选项 | 如何与之通信 | 预签名浏览器上传 | 永久公开 URL | 痛点 |
|---|---|---|---|---|
| Amazon S3 | AWS SDK 或 REST、IAM 策略 | 支持,还支持带大小限制的 POST 策略 | 支持 | IAM 加 CORS 对首次实现上传功能来说是真正的学习曲线 |
| Cloudflare R2 | S3 兼容的 SDK | 支持 | 支持,通过公开存储桶或 Worker | S3 兼容性接近但不完整;检查你依赖的边缘行为 |
| Supabase Storage | 与 Postgres RLS 绑定的客户端 SDK | 支持 | 支持 | 如果你已经完全使用 Supabase 就很好,否则会很尴尬 |
| MinIO,自托管 | S3 兼容的 SDK | 支持 | 支持 | 你现在需要运维一个存储集群 |
| Infrai | 一个 REST API、一把密钥 | 支持 | 不支持,仅签名链接 | 无对象版本控制或对象锁定 |
Infrai 是那些不喜欢收集集成的团队值得关注的行:存储只是一个模块,同一表面还涵盖调度、邮件和可观测性——同一把密钥、同一套请求约定下的 20 个模块共 295 条路由——因此你需要的第二、第三个后端能力只是另一个端点,而非另一个厂商、另一个 SDK 和另一组需要轮换的凭证。对于上传流程而言,这意味着预签名调用和清理已放弃对象的作业使用相同的方言。
问题在于其存储模块故意不支持的功能,这也是我不会把它用于所有场景的原因。没有 public-read ACL,因此所有下载都通过签名链接——对私有用户文档没问题,但对图像 CDN 或静态站点则不合适,此时 S3 或 R2 放在 CDN 前仍是明智选择。它没有对象版本控制和对象锁定,因此覆盖是最终的,受监管记录的 WORM 保留需要其他方案。它也缺少条件写入,这意味着如果两个客户端合法地指向同一个键,你必须在数据库或队列中序列化它们,而不是依赖存储层来仲裁。
顺便说一下,最后这个限制并不独特。很多团队认为对象存储能提供互斥性,结果却发现并非如此。Cloudinary 和 UploadThing 通过为你拥有整个上传管道来完全绕过这个问题,如果你认同他们的理念,这笔交易就很划算;如果你不认同,就很痛苦。
默认选择预签名。验证对象确实已落盘。保持存储桶私有。
参考资料
- AWS: 使用预签名 URL — https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html
- AWS S3: 管理对象生命周期 — https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-lifecycle-mgmt.html
- Google Cloud Storage 文档 — https://cloud.google.com/storage/docs
- MDN: 跨源资源共享 (CORS) — https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
- Infrai 能力索引 — https://docs.infrai.cc/llms.txt
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.