本节把 TaskHub 的附件存储抽成
ObjectStore接口,接到 S3 兼容对象存储;并让浏览器直连对象存储上传下载,应用进程只负责签发一张有时效的「通行证」。
上一节我们把文件安全地写进了本地磁盘。但一旦 TaskHub 要部署到 Kubernetes(第 15 章),本地磁盘立刻变成三个问题:多副本看不到彼此的文件、Pod 重建文件全丢、扩容时老文件留在旧节点上。附件必须放到进程之外的地方——对象存储。
这一节解决两件事:怎么用一层接口把存储实现换掉,以及怎么用预签名 URL 让文件流量根本不经过我们的 Go 进程。
13.2.1 为什么是对象存储,不是共享磁盘
在选型前先想清楚附件这种数据的访问模式:写一次、读很多次、几乎不修改、按 key 直取、单个对象几 KB 到几 GB。这正是对象存储(S3 及其兼容实现)的设计目标。对比一下三种方案:
| 方案 | 多副本共享 | 容量 | 适合附件 | 代价 |
|---|---|---|---|---|
| 本地磁盘 / emptyDir | 否 | 受节点盘限制 | 否 | Pod 重建即丢 |
| 网络文件系统(NFS/EFS) | 是 | 中 | 勉强 | 元数据性能差、语义弱 |
| 对象存储(S3/MinIO) | 是 | 近无限 | 是 | 需要 SDK、最终一致性取舍 |
TaskHub 选对象存储。所谓「S3 兼容」指的是 MinIO、Ceph RGW、阿里云 OSS、腾讯云 COS、AWS S3 这些实现共用同一套 HTTP API 与签名算法——同一份代码,改个 endpoint 就能换供应商,这是它最大的工程价值。
13.2.2 用接口把存储层关进笼子
不要让业务代码直接调 minio-go。先定义一个最小接口,把「对象存储能干什么」说清楚:
// ObjectStore 是 TaskHub 对「对象存储」的唯一抽象
type ObjectStore interface {
Put(ctx context.Context, key string, r io.Reader, size int64, contentType string) error
Get(ctx context.Context, key string) (io.ReadCloser, error)
PresignPut(ctx context.Context, key string, ttl time.Duration) (string, error)
PresignGet(ctx context.Context, key string, ttl time.Duration) (string, error)
}
四个方法对应四件事:服务端直传、服务端直读、签发上传 URL、签发下载 URL。接口这么小是有意的——接口越小,假实现越好写,测试越不依赖真实存储。
本地开发用一个文件系统实现顶上:
type LocalStore struct{ root string }
func (l *LocalStore) Put(_ context.Context, key string, r io.Reader, _ int64, _ string) error {
p := filepath.Join(l.root, filepath.Clean("/"+key))
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
return err
}
f, err := os.Create(p)
if err != nil {
return err
}
defer f.Close()
_, err = io.Copy(f, r)
return err
}
注意 filepath.Clean("/"+key) 这个写法:前面拼一个 / 再 Clean,能把 ../ 这类相对成分归一化掉,是 key 来自外部时的一道防线。
S3 实现则把四个方法转发给 minio-go:
type S3Store struct {
cli *minio.Client
bucket string
}
func (s *S3Store) Put(ctx context.Context, key string, r io.Reader, size int64, ct string) error {
_, err := s.cli.PutObject(ctx, s.bucket, key, r, size, minio.PutObjectOptions{ContentType: ct})
return err
}
func (s *S3Store) Get(ctx context.Context, key string) (io.ReadCloser, error) {
return s.cli.GetObject(ctx, s.bucket, key, minio.GetObjectOptions{})
}
实测两者都能跑通:
local put/get: "hello"
s3 presign put ok: true true
关键点是业务层只认 ObjectStore。 单元测试注入 LocalStore,集成测试注入 S3Store(指向 MinIO 容器),生产注入真 S3。切换实现不改业务代码一行——这是第 1 章「依赖方向」在存储层的具体落地。
13.2.3 minio-go 客户端:Region 决定了签名能不能离线算
初始化客户端时有个坑必须知道。minio.New 默认会先向服务端查一次 bucket 所在的 region(GET /<bucket>/?location=),这一步是网络请求。如果你只是想在本地生成预签名 URL(比如把签发逻辑放在一个不直连存储的服务里),这个查询会让签名失败:
panic: Get "https://s3.example.com/taskhub/?location=": dial tcp: lookup s3.example.com: no such host
解决办法是显式指定 Region,客户端就跳过 location 查询,纯本地完成签名:
cli, err := minio.New("s3.example.com", &minio.Options{
Creds: credentials.NewStaticV4(accessKey, secretKey, ""),
Secure: true,
Region: "us-east-1", // 指定后不再联网查 region
})
这一点在真实架构里很重要:签发预签名 URL 的服务可以是无状态的、不持有存储凭证连接的服务,它只需要 access key / secret 就能算签名。
13.2.4 预签名 URL:把流量从应用进程卸载
朴素做法是「浏览器 → 应用 → 对象存储 → 应用 → 浏览器」,文件流量两次穿过应用进程。1GB 的附件会让应用进程的带宽和连接数被白白吃掉。预签名 URL 换了一条路:
朴素:浏览器 --文件--> TaskHub API --文件--> 对象存储
预签:浏览器 --文件--------------------------> 对象存储
TaskHub API 只回一个带签名的 URL
应用只签发一张有时效的通行证,浏览器拿着它直连对象存储。minio-go 两个方法搞定:
// 上传:客户端拿到 URL 后直接 PUT
pu, err := cli.PresignedPutObject(ctx, "taskhub", "tenant-1/task-42/upload.bin", 10*time.Minute)
// 下载:可以让客户端下载时就带上想要的响应头
gu, err := cli.PresignedGetObject(ctx, "taskhub", "tenant-1/task-42/report.pdf", 15*time.Minute,
url.Values{"response-content-disposition": []string{`attachment; filename="report.pdf"`}})
真实签出来的 URL:
GET presign: https://s3.example.com/taskhub/tenant-1/task-42/report.pdf?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20261010%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20261010T024542Z&X-Amz-Expires=900&X-Amz-SignedHeaders=host&response-content-disposition=attachment%3B%20filename%3D%22report.pdf%22&X-Amz-Signature=...
PUT presign: https://s3.example.com/taskhub/tenant-1/task-42/upload.bin?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=...&X-Amz-Date=20261010T024542Z&X-Amz-Expires=600&X-Amz-SignedHeaders=host&X-Amz-Signature=...
读一下这个 URL 的几个参数:
| 参数 | 含义 | 备注 |
|---|---|---|
X-Amz-Algorithm | 签名算法 | 固定 AWS4-HMAC-SHA256 |
X-Amz-Credential | access key + 日期 + region + 服务 | 这里服务是 s3 |
X-Amz-Date | 签名的时刻(UTC) | 服务端据此判断是否过期 |
X-Amz-Expires | 有效期(秒) | 上传 600s,下载 900s |
X-Amz-SignedHeaders | 被签进请求的 header | 这里只签了 host |
X-Amz-Signature | 最终签名 | 改一个字符就 403 |
注意 X-Amz-Expires 是唯一的时间闸门,服务端不看别的。所以签发接口一定要按用途给不同的 TTL:上传 URL 给几分钟,下载 URL 给十几分钟,绝不给几小时或几天。
13.2.5 亲手算一遍 SigV4
预签名 URL 看着神秘,其实就是 HMAC-SHA256 的确定性计算。理解了它,出问题时(签名不匹配、时钟偏移、编码不一致)你才能自己定位。整个流程分四步:
第一步,构造 Canonical Request(规范化请求):
GET
/taskhub/tenant-1/task-42/report.pdf
X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=...&X-Amz-Date=...&X-Amz-Expires=900&X-Amz-SignedHeaders=host&response-content-disposition=...
host:s3.example.com
host
UNSIGNED-PAYLOAD
六行分别是:HTTP 方法、规范化 URI、规范化查询串、规范化 header、被签的 header 列表、payload 的 hash。预签名请求用 UNSIGNED-PAYLOAD 占位——因为签名时根本还没读 body。
第二步,构造 String to Sign:
AWS4-HMAC-SHA256
20261010T021741Z
20261010/us-east-1/s3/aws4_request
<Canonical Request 的 SHA-256 十六进制>
第三步,层层派生签名密钥:
kDate := hmacSHA256([]byte("AWS4"+secretKey), date) // date = "20261010"
kRegion := hmacSHA256(kDate, region) // "us-east-1"
kService := hmacSHA256(kRegion, "s3")
kSigning := hmacSHA256(kService, "aws4_request")
第四步,签名:
sig := hex.EncodeToString(hmacSHA256(kSigning, stringToSign))
有两个最容易踩的细节:
- 百分号编码规则是 AWS 自己的,不是
url.QueryEscape。只有A-Za-z0-9-_.~不编码,空格编成%20而不是+,/在路径里不编码、在查询值里编码。写成url.QueryEscape会得到+,签名必然不匹配。 - 查询串必须先按 key 字典序排序再拼接,否则顺序不同、签名不同。
我把上面四步手写了一遍,和 minio-go 用同一组输入签出来的结果对比:
hand-rolled: ...&X-Amz-Signature=dd64d966376b9d2bd30707f5dec4dd560d98ae0f07b01da5d9662f89d656b943
minio-go : dd64d966376b9d2bd30707f5dec4dd560d98ae0f07b01da5d9662f89d656b943
MATCH: true
逐字节一致。 这就是预签名的全部秘密:没有加密,只有确定性的 HMAC 链。
13.2.6 端到端验证:本地验签服务器
只签出 URL 还不能说明它「能用」。我写了一个最小的 HTTP 服务端扮演对象存储:它用同样的算法在服务端重算签名,不匹配就拒绝。客户端则用 minio-go 签发的 URL 真发请求。实测三种情况:
PUT presigned -> 200 stored 28 bytes
PUT tampered -> 403
GET presigned -> 200 body="hello taskhub object storage"
读法:正常预签名 PUT 返回 200 并存入 28 字节;把签名改一个字符后立刻 403(证明签名真的在起作用,不是摆设);预签名 GET 拿回原文。
必须诚实说明:本节所有 S3 相关的验证都在这个「本地验签服务器」上完成,不是真实 S3 或 MinIO。 本机 docker pull minio/minio 实测失败(镜像仓库不可达),因此真实 MinIO 服务端未实测。不过:
- 预签名 URL 的生成由
minio-gov7.3.0 完成,是官方库的真实行为; - 手写 SigV4 与官方库输出逐字节一致,证明签名算法理解正确;
- 验签服务器按同一算法独立实现,端到端证明了「签名 → 验签 → 放行/拒绝」闭环。
真实部署时仍需在真 MinIO / S3 上跑一遍冒烟测试,重点验证 CORS 配置与时钟同步——这两点在本地验签服务器上体现不出来。
13.2.7 预签名安全清单
| 风险 | 缓解措施 |
|---|---|
| URL 泄露后被长期滥用 | TTL 尽量短(上传 5–10 分钟,下载 10–30 分钟) |
| 客户端改写 key 上传到别人的目录 | key 必须服务端生成,包含租户前缀,客户端只拿结果 |
| 上传超大文件 | 预签名 PUT 无法限大小,改走 POST Policy 或服务端中转 |
| 上传可执行内容被当静态资源 | bucket 设为私有 + 下载走 Content-Disposition: attachment |
| 时钟偏移导致签名无效 | 容器/节点必须开 NTP;偏移超过几分钟签名就废 |
| 浏览器跨域被拦 | bucket 配 CORS,AllowedOrigin 精确到域名,不要用 * |
三条最容易被忽略的:
- key 必须服务端拼。预签名 URL 里已经固化了 key,客户端改不了路径——这正是它的安全价值。如果客户端能自定义 key,就等于把 bucket 写权限交出去了。
- 预签名 PUT 管不住大小。签名只绑定了路径和 header,body 大小不受约束。要限大小必须用 POST Policy 的
content-length-range,或者干脆让文件先走服务端中转(此时用上一节的MaxBytesReader)。 - 私有 bucket + 预签名下载是默认姿势。永远不要为了让「图片能直接打开」就把 bucket 设成公开读——那等于把全部租户的文件暴露给任何人枚举。
小结
- 多副本部署下本地磁盘留不住附件,对象存储是唯一能水平扩展的选择。
- 用
ObjectStore接口隔离实现,业务层不认minio-go,测试注入LocalStore。 minio.New加Region可跳过联网查 region,让签名服务无状态化。- 预签名 URL 把文件流量从应用进程卸载,应用只签发有时效的通行证。
- SigV4 是确定性的 HMAC 链;手写实现与
minio-go输出逐字节一致(实测)。 - 编码规则是 AWS 自己的(空格
%20),查询串必须字典序排序——这是签名不匹配的两大元凶。 - 预签名 PUT 无法限制大小,需要限流就走 POST Policy 或服务端中转。
- 真实 MinIO 服务端本机未实测(镜像拉取失败),验证基于本地验签服务器。
预签名 URL 解决了「整文件一次传完」的场景。但移动端网络不稳定,一个 800MB 的文件传到 90% 断线,用户不可能从头再来。下一节我们实现断点续传:服务端记住已收到的偏移,客户端从断点接着传。
阅读导航:上一节:13.1 上传下载与流式处理 · 下一节:13.3 断点续传与大文件 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。