目录

Minio的SDK访问其他对象存储的问题

问题背景

Minio 是目前非常流行的对象存储组件,不仅提供对象存储的服务,Minio 各种语言的 SDK 也应用非常广泛,比如在 WandB 里就用了 Minio 来存机器学习训练产生的指标、日志以及图像文件等一些 artifact,同时 WandB 还可以配置其他类型的 S3 对象存储,WandB 的服务端是 Go 写的,因此用了 Minio 的 Go SDK。

公司有自研的对象存储,用 X3 来指代吧,在公司内部使用同样非常广泛,X3 的接口实现可以参考接口手册,S3 协议部分接口已经有迭代更新到 V2,目前 X3 有些接口没有完全实现。

当前很多对象存储的网页版或者 Native 版本的软件都是通过 s3v4 的方式来访问对象存储,例如通过 Minio Console 或者老版本的 Minio Browser 是 Hard Code 的 s3v4 配置去访问对象存储,当访问 X3 的时候,目前是无法实现的,在鉴权的过程就会报错,因此无法实现通过这些客户端访问 X3 的需求,同样的问题也会在使用 Minio Go SDK 的应用上。

另外,Minio Go SDK 通过 s3v2 访问 X3 本身是没问题的,只是 s3v4 安全性更高,也更流行,当前很多软件已经完全抛弃 s3v2 的方式访问 S3 对象储存了。

问题原因

先说结论,本质原因是 X3 没有实现 https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetBucketLocation.html S3 这个标准接口,Minio Go SDK 通过 s3v4 访问对象存储的时候,比如 ListObject 实际会发送两次请求,第一次请求会先发查看 Bucket 的 Location 信息的接口,根据 Response 拿到 Location 字段,并且 Location 字段会作为第二次请求 ListObject 的签名 Header 重新签名发送的请求。

因为 X3 没有实现这个接口 API_GETBucketLocation,因此实际请求发送到 X3 中,会被 GetBucket 接口处理(或者其他接口),而这个接口返回的内容中没有 Location 字段!!但在 Minio Go SDK 中仍然会通过 XML 的 Decoder 去解析 Response,这样就会返回一个不正常的结果,Location 会变成换行符参与到签名中,而在 HTTP 的 Header 中是无法处理换行的,因此客户端在第二次请求的时候,会因为 Header 不正常,直接报错,请求甚至没有发送到 X3。

问题分析

上面的原因分析,可以通过 Minio 的客户端得到验证。

/minio%E7%9A%84sdk%E8%AE%BF%E9%97%AE%E5%85%B6%E4%BB%96%E5%AF%B9%E8%B1%A1%E5%AD%98%E5%82%A8%E7%9A%84%E9%97%AE%E9%A2%98/img.png

第一次请求,请求的正是 API_GETBucketLocation。

1
2
3
4
5
6
7
mc: <DEBUG> GET /tendis-exported-files/?location= HTTP/1.1
Host: storegw-staging.api.x3.com
User-Agent: MinIO (darwin; amd64) minio-go/v7.0.73 mc/RELEASE.2024-07-15T17-46-06Z
Accept-Encoding: zstd,gzip
Authorization: AWS4-HMAC-SHA256 Credential=**REDACTED**/20240718/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=**REDACTED**
X-Amz-Content-Sha256: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
X-Amz-Date: 20240718T083434Z

第二次请求,会因为 invalid header field value for “Authorization” 被终止。

1
2
3
4
mc: <ERROR> Unable to list folder. Get "http://storegw-staging.api.x3.com/tendis-exported-files/?delimiter=%2F&encoding-type=url&fetch-owner=true&list-type=2&prefix=": net/http: invalid header field value for "Authorization"
(1) ls.go:239 cmd.doList(..) Tags: [http://storegw-staging.api.x3.com/tendis-exported-files/]
 (0) client-s3.go:2376 cmd.(*S3Client).listInRoutine(..)
Release-Tag:RELEASE.2024-07-15T17-46-06Z | Commit:11034f9de1e9 | Host:C02WG24LHTD7 | OS:darwin | Arch:amd64 | Lang:go1.22.5 | Mem:4.7 MiB/18 MiB | Heap:4.7 MiB/11 MiB

按照以往使用 S3 相关的客户端的经验,我们在 ListObject 的时候,正常就应该直接使用 ListObject 的接口就可以了,那正常来说应该就是一个请求,为什么会有这个 API_GETBucketLocation 接口的调用呢?实际上,很多 S3 的 SDK 和 CLI 都没有在 ListObject 之前会调用 API_GETBucketLocation,大概只有 Minio 有这样的实现,可以通过查看 Minio Go SDK 的源码去证实。

newRequest 这个方法,是构建 S3 请求必经方法,可以看到,在 Minio Go SDK 里,不管什么请求,只要 metadata 里没有bucketLocation,都会去获取 Bucket 的 Location,然后会写到请求中的 metadata 里,这样下次请求就不需要再请求 API_GETBucketLocation 这个接口了。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// https://github.com/minio/minio-go/blob/v7.0.74/api.go#L758
// newRequest - instantiate a new HTTP request for a given method.
func (c *Client) newRequest(ctx context.Context, method string, metadata requestMetadata) (req *http.Request, err error) {
// If no method is supplied default to 'POST'.
if method == "" {
method = http.MethodPost
}

	location := metadata.bucketLocation
	if location == "" {
		if metadata.bucketName != "" {
			// Gather location only if bucketName is present.
			location, err = c.getBucketLocation(ctx, metadata.bucketName)
			if err != nil {
				return nil, err
			}
		}
		if location == "" {
			location = getDefaultLocation(*c.endpointURL, c.region)
		}
	}
...
...
}

上面说到 metadata 里没有bucketLocation,都会去获取 Bucket 的 Location,下面再看看这个 Location 是怎么获取的,其中 getBucketLocationRequest 后台会封装一个 HTTP 请求,请求 API_GETBucketLocation,然后获取的结果,通过下面的 processBucketLocationResponse 解析。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// https://github.com/minio/minio-go/blob/v7.0.74/bucket-cache.go#L85

	// Initialize a new request.
	req, err := c.getBucketLocationRequest(ctx, bucketName)
	if err != nil {
		return "", err
	}

	// Initiate the request.
	resp, err := c.do(req)
	defer closeResponse(resp)
	if err != nil {
		return "", err
	}
	location, err := processBucketLocationResponse(resp, bucketName)
	if err != nil {
		return "", err
	}

那么在 X3 没有实现 API_GETBucketLocation 的情况下,强行请求 API_GETBucketLocation 接口是什么结果呢?参考下面 debug 的过程,虽然 X3 没有实现 API_GETBucketLocation,但 X3 依然会返回结果,结果在下面第二个图。

/minio%E7%9A%84sdk%E8%AE%BF%E9%97%AE%E5%85%B6%E4%BB%96%E5%AF%B9%E8%B1%A1%E5%AD%98%E5%82%A8%E7%9A%84%E9%97%AE%E9%A2%98/img_1.png /minio%E7%9A%84sdk%E8%AE%BF%E9%97%AE%E5%85%B6%E4%BB%96%E5%AF%B9%E8%B1%A1%E5%AD%98%E5%82%A8%E7%9A%84%E9%97%AE%E9%A2%98/img_2.png

可以想象,如果 Minio Go SDK 拿着这个 Response 去解析 Location 肯定是无法正常解析的,在下面的 debug 过程里可以验证,解析出来的是一堆换行符!

/minio%E7%9A%84sdk%E8%AE%BF%E9%97%AE%E5%85%B6%E4%BB%96%E5%AF%B9%E8%B1%A1%E5%AD%98%E5%82%A8%E7%9A%84%E9%97%AE%E9%A2%98/img_3.png

拿着一堆换行符去构建 Header 肯定是有问题的,这可以在 Minio Go SDK 里加几行日志去验证,很明显,这个头已经有大问题了。

/minio%E7%9A%84sdk%E8%AE%BF%E9%97%AE%E5%85%B6%E4%BB%96%E5%AF%B9%E8%B1%A1%E5%AD%98%E5%82%A8%E7%9A%84%E9%97%AE%E9%A2%98/img_4.png

问题总结

上述讨论的这个问题,可能只存在于 Minio 的 SDK 中,这跟 SDK 本身的实现有很大关系,因为 SDK 中写死的逻辑就是在 ListObject 的请求之前都会获取 Bucket 的 Location 用于签名,这是出于 Minio 自身设计有关的,因此在其他支持 s3v4 的 SDK 或者 CLI 中,可能是不会存在这个问题,也就是可以正常通过 s3v4 来访问 X3

下面是 aws s3 CLI 的结果,可以很清楚看到,CLI 里的逻辑 ListObject 之前并不会去请求 API_GETBucketLocation,更不会拿这个 Location 去做后面请求的签名。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
aws s3 ls s3://tendis-exported-files/  --endpoint-url http://storegw-staging.api.x3.com --debug
2024-07-18 15:53:56,306 - MainThread - awscli.clidriver - DEBUG - CLI version: aws-cli/2.17.11 Python/3.11.9 Darwin/21.6.0 source/x86_64
2024-07-18 15:53:56,307 - MainThread - awscli.clidriver - DEBUG - Arguments entered to CLI: ['s3', 'ls', 's3://tendis-exported-files/', '--endpoint-url', 'http://storegw-staging.api.x3.com', '--debug']
...
2024-07-18 15:53:56,512 - MainThread - botocore.auth - DEBUG - Calculating signature using v4 auth.
2024-07-18 15:53:56,512 - MainThread - botocore.auth - DEBUG - CanonicalRequest:
GET
/tendis-exported-files
delimiter=%2F&encoding-type=url&list-type=2&prefix=
host:storegw-staging.api.x3.com
x-amz-content-sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
x-amz-date:20240718T075356Z

host;x-amz-content-sha256;x-amz-date
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
2024-07-18 15:53:56,512 - MainThread - botocore.auth - DEBUG - StringToSign:
AWS4-HMAC-SHA256
20240718T075356Z
20240718/us-east-1/s3/aws4_request
7833eb257daa7c6b2a5fafde3a6ddb5f94ba125f853feae1c9065e803b9cf4aa
2024-07-18 15:53:56,512 - MainThread - botocore.auth - DEBUG - Signature:
52cf6095efd7faeb68582aa60cae8051ad179d656a41eae96219a87a9c535810
...
...

问题解决

解决的方案有很多,可以硬改 SDK,也可以通过 X3 的 Nginx 去返回 API_GETBucketLocation 的结果,但比较合理的应该是在 X3 里实现 API_GETBucketLocation 接口。

解决这个问题的意义在于以后在使用依赖 Minio 的 SDK 的工具和组件的时候,都可以使用安全性更高的 s3v4 正常访问 X3。

注意
本文最后更新于 2024年7月18日,文中内容可能已过时,请谨慎参考。