那个 502
在终端里粘贴图片,美国部署下返回 502。当时的上传链路是 multipart proxy:浏览器把文件 POST 到服务端,服务端再流式上传到 S3,然后返回对象的 presigned URL。服务端的 HTTP 超时是 15 秒。对一个美国客户端来说,经服务端中转、服务端再去找 S3,光是 proxy 到 S3 这一段往返就可能超出这个预算,请求在对象落完之前就被掐断了。
服务端拿到这些字节后什么都没做,只是转发:缓冲 multipart body、打开文件、调用 storage.Upload(...)——纯粹的 pass-through,但这个 pass-through 要计入请求超时,而且每次上传都占用服务端内存和带宽。
为什么数据不该走 proxy
proxy 设计里叠了两层成本:
- 延迟预算。 客户端可见的那个请求,要在整个 客户端→服务端→S3 传输期间一直挂着。跨区域时,它和一个固定的 15 秒服务端超时直接冲突——而这个超时在别处是有正当理由存在的。为了迁就上传去全局调大它,是用错了杠杆。
- 服务端在数据通路上。 每个字节都过服务端进程。它不带来任何价值:没有转换,没有需要 body 的校验,没有任何人会读的记录。
最后这点后来证明很关键。旧链路还会写一条 file DB 记录(上传者、原始文件名、storage key、mime、size),并暴露 GetByID、GetURL(id)、DELETE /files/:id。grep 一遍消费者,一个都没有:没人把这条记录读回来,没人调 delete。这份元数据被持久化,是基于「它将来会有用」的假设,而不是因为有什么真的依赖它。
Presigned PUT + GET
替换方案(PR #131)把服务端保留在控制通路上,从数据通路上拿掉。浏览器向服务端要两个 presigned URL,然后直接和 S3 对话:
POST /api/v1/orgs/:slug/files/presign
{ "filename": "...", "content_type": "image/png", "size": 12345 }
→ { "put_url": "...", "get_url": "..." }
服务端只负责校验、签名、返回。service 本体很短:
func (s *Service) RequestPresignedUpload(ctx context.Context, req *PresignUploadRequest) (*PresignUploadResponse, error) {
maxSize := s.config.MaxFileSize * 1024 * 1024
if req.Size > maxSize {
return nil, fmt.Errorf("%w: max size is %d MB", ErrFileTooLarge, s.config.MaxFileSize)
}
if !s.isAllowedType(req.ContentType) {
return nil, fmt.Errorf("%w: %s", ErrInvalidFileType, req.ContentType)
}
storageKey := s.generateStorageKey(req.OrganizationID, req.FileName)
putURL, err := s.storage.PresignPutURL(ctx, storageKey, req.ContentType, 15*time.Minute)
// ...
getURL, err := s.storage.GetURL(ctx, storageKey, 24*time.Hour)
// ...
}
客户端是三步序列——presign、PUT 到 S3、返回 GET URL:
const { put_url, get_url } = await (await fetch(`${API_BASE_URL}/.../files/presign`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({ filename: file.name, content_type: file.type, size: file.size }),
})).json();
await fetch(put_url, { method: "PUT", headers: { "Content-Type": file.type }, body: file });
return get_url; // 24 小时有效
服务端不再碰 body,它的超时也就不再约束传输。传输现在发生在浏览器和 S3 之间,本就该在那里。
哪些东西不下放到客户端
直传 S3 不等于信任客户端。三项约束留在服务端,因为 presign 请求是服务端唯一还能控制的那个点:
- size 和 MIME 校验 在签名前执行。size 限制是建议性的——S3 不会强制 body 与声明的
size一致;但 MIME 校验是有意义的,因为Content-Type被烧进了签名。 - storage key 由服务端生成,绝不接受客户端传入。 格式:
orgs/{org_id}/files/{year}/{month}/{uuid}{ext}。客户端无法给对象命名,也就无法覆盖别的 org 的 key 或逃出租户前缀。 - 有效期是非对称的,且刻意如此。 PUT 签 15 分钟——够一次上传,又短到泄露的 URL 很快过期。GET 签 24 小时,按一个粘贴出来的图片链接需要解析多久来定。
Content-Type 是个微妙点。PresignPutObject 把它绑进 SigV4 签名,所以客户端在 PUT 上发的 Content-Type 必须等于它在 /presign 时声明的那个,否则 S3 拒签。两个请求之间的契约由 S3 强制,不靠我们自己。
两个 endpoint,一个签名坑
S3 presign 会把 host 签进去。浏览器作客户端时我们对 public endpoint 签名,服务间上传则对 internal endpoint 签名(Runner 走 Docker 网络上传诊断日志,用的是同一套),因为对一个 host 签出来的 URL 发给另一个 host 会 403:
// 当 public endpoint 与 internal 不同时,用绑定到 public host 的
// presign client,使 SigV4 签名匹配客户端实际会去连的 host。
presigner := s.presign
if s.publicPresign != nil {
presigner = s.publicPresign
}
浏览器上传还需要 bucket 上的 CORS。服务端从通路上拿掉之后,这个 PUT 是直接打到 S3/MinIO 的跨域请求,所以每个 docker-compose 都加了 MINIO_API_CORS_ALLOW_ORIGIN。后来一次排查发现了对称的读侧 bug:GetURL 返回的是未签名的 public URL,在默认私有的 MinIO bucket 上 403,于是它现在也走 public presign client。
结果
这次改动删的比加的多:24 个文件,211 行新增、1102 行删除。 没了 multipart handler、file DB 记录及其 repository、domain/file 包、DELETE /files/:id endpoint。留下的是一个「校验加签名」的 endpoint,和一个在已有 GetURL 旁边多了 PresignPutURL 的 storage 接口。
可迁移的结论:如果一个服务转发它既不检查也不存储的字节,这些字节就不该过它。Presigned URL 让服务端继续做策略决策点——鉴权、key 命名空间、类型与有效期——而数据走直连通路。校验、key 生成、有效期留在服务端;把 Content-Type 和签名的 host 交给 S3 替你强制。