《Go 语言编程实战》13.2 S3 兼容对象存储与预签名

把 TaskHub 的附件从本地磁盘搬到 S3 兼容对象存储:先用 ObjectStore 接口把实现隔离,再用 minio-go 生成预签名 URL 把文件流量从应用进程卸载,最后亲手实现一遍 SigV4 签名算法,实测与官方库签出的结果逐字节一致。

本节把 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-Credentialaccess 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))

有两个最容易踩的细节:

  1. 百分号编码规则是 AWS 自己的,不是 url.QueryEscape。只有 A-Za-z0-9-_.~ 不编码,空格编成 %20 而不是 +,/ 在路径里不编码、在查询值里编码。写成 url.QueryEscape 会得到 +,签名必然不匹配。
  2. 查询串必须先按 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-go v7.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 精确到域名,不要用 *

三条最容易被忽略的:

  1. key 必须服务端拼。预签名 URL 里已经固化了 key,客户端改不了路径——这正是它的安全价值。如果客户端能自定义 key,就等于把 bucket 写权限交出去了。
  2. 预签名 PUT 管不住大小。签名只绑定了路径和 header,body 大小不受约束。要限大小必须用 POST Policy 的 content-length-range,或者干脆让文件先走服务端中转(此时用上一节的 MaxBytesReader)。
  3. 私有 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 断点续传与大文件 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练