如何计算大于5GB的文件的Amazon-S3 Etag算法?

106

上传到Amazon S3的文件,如果文件大小小于5GB,则ETag只是文件的MD5哈希值,这使得检查本地文件是否与在S3上的文件相同变得容易。

但如果您的文件大于5GB,则Amazon会以不同的方式计算ETag。

例如,我将一个大小为5,970,150,664字节的文件分成了380个部分进行多部分上传。现在S3显示其具有ETag 6bcf86bed8807b8e78f0fc6e0a53079d-380。 我的本地文件具有MD5哈希值702242d3703818ddefe6bf7da2bed757。我认为短横线后面的数字是多部分上传中的部件数。

我还怀疑新的ETag(短横线前面)仍然是MD5哈希,但在多部分上传过程中包含了某些元数据。

有人知道如何使用与Amazon S3相同的算法计算ETag吗?


19
需要澄清的是,问题并不在于如果文件超过5GB,ETag算法会发生变化。针对非分段上传和分段上传,ETag算法是不同的。如果以一个5MB部分和一个1MB部分上传6MB文件,则会遇到相同的问题计算ETag。MD5用于非分段上传,其上限为5GB。我的答案中介绍的算法用于分段上传,其每个部分的上限为5GB。 - Emerson Farrugia
如果启用了服务器端加密,情况也会有所不同。我认为etag可能应该被视为实现细节,而不应该依赖于客户端。 - wim
@wim 你有没有想法在启用SSE时如何计算ETag? - Avihoo Mamka
1
不可能的。我甚至不认为这是可能的 - 从etag本身推断出任何关于内容的信息都与加密的目标背道而驰,如果已知有效载荷可以可预测地产生相同的etag,则这将是一种信息泄漏。 - wim
22个回答

113

假设您已经上传了一个大小为14MB的文件到一个没有开启服务器端加密的存储桶中,而您的分片大小是5MB。计算每个部分对应的3个MD5校验和,即前5MB、第二个5MB和最后4MB的校验和。然后取它们拼接后的校验和。 MD5校验和通常被打印为二进制数据的十六进制表示形式,因此请确保您使用解码后的二进制拼接的MD5校验和,而不是ASCII或UTF-8编码的拼接。完成后,在连字符后添加部分数量以获取ETag。

以下是在Mac OS X控制台上执行此操作的命令:

$ dd bs=1m count=5 skip=0 if=someFile | md5 >>checksums.txt
5+0 records in
5+0 records out
5242880 bytes transferred in 0.019611 secs (267345449 bytes/sec)
$ dd bs=1m count=5 skip=5 if=someFile | md5 >>checksums.txt
5+0 records in
5+0 records out
5242880 bytes transferred in 0.019182 secs (273323380 bytes/sec)
$ dd bs=1m count=5 skip=10 if=someFile | md5 >>checksums.txt
2+1 records in
2+1 records out
2599812 bytes transferred in 0.011112 secs (233964895 bytes/sec)

此时所有校验和都在 checksums.txt 中。要连接它们、解码十六进制并获取全部的 MD5 校验和,只需使用:

cat checksums.txt | xargs echo -n | openssl dgst -md5
$ xxd -r -p checksums.txt | md5

现在添加"-3"以获取ETag,因为有3个部分。

  • 如果使用aws-cli通过aws s3 cp上传,则最可能具有8MB的块大小。根据文档,这是默认值。
  • 如果存储桶启用了服务器端加密(SSE),则ETag将不是MD5校验和(请参见API文档)。但是,如果您只是想验证已上传的部分与发送的内容匹配,可以使用Content-MD5标题,并且S3将为您比较
  • macOS上的md5只会写出校验和,但是Linux / brew上的md5sum也会输出文件名。您需要剥离它,但我确定有一些选项仅输出校验和。不必担心空格,因为xxd将忽略它。

代码链接


1
有趣的发现,希望亚马逊不会更改它,因为这是未记录的功能。 - sanyi
1
好的观点。根据HTTP规范,ETag完全由他们自行决定,唯一的保证是他们不能为已更改的资源返回相同的ETag。虽然我猜想改变算法并没有太多优势。 - Emerson Farrugia
2
有没有一种方法可以通过ETag计算“部分大小”? - DavidGamba
1
计算,不是猜测。如果ETag以“-4”结尾,则知道有四个部分,但最后一个部分的大小可能只有1字节,也可能与其他部分一样大。因此,将文件大小除以部分数量可以给出一个估计值,但当部分数量很少时,例如-2,就更难猜测了。如果您有多个使用相同部分大小上传的文件,还可以查找相邻的部分计数,例如-4和-5,并缩小部分大小的范围,例如,在-2处为1.9MB,在-3处为2.1MB,这意味着部分大小为2MB加减100KB。 - Emerson Farrugia
5
我认为如果AWS不将其哈希算法公开作为合同,特别是当您验证数据完整性时会影响应用程序的正确性,那么依赖于AWS的内部实现可能不明智。 - iman
显示剩余8条评论

31

根据这里的答案,我编写了一个Python实现,可以正确计算多部分和单部分文件的ETag。

def calculate_s3_etag(file_path, chunk_size=8 * 1024 * 1024):
    md5s = []

    with open(file_path, 'rb') as fp:
        while True:
            data = fp.read(chunk_size)
            if not data:
                break
            md5s.append(hashlib.md5(data))

    if len(md5s) < 1:
        return '"{}"'.format(hashlib.md5().hexdigest())

    if len(md5s) == 1:
        return '"{}"'.format(md5s[0].hexdigest())

    digests = b''.join(m.digest() for m in md5s)
    digests_md5 = hashlib.md5(digests)
    return '"{}-{}"'.format(digests_md5.hexdigest(), len(md5s))

默认的块大小为8 MB,由官方的aws cli工具使用,并且对2个或更多块进行了多部分上传。它应该在Python 2和3下都可以工作。


我的块大小似乎是16MB,使用官方的AWS CLI工具,也许他们更新了? - SerialEnabler
这对我在一个大小约为20GB的文件上使用8MB块大小有效。我使用aws cli 2.1.15上传到s3,使用深度归档存储类别。 - jtbandes
太好了!200GB已确认,谢谢!如果可以的话,我会双倍点赞。 - Bruce Edge
通过浏览器上传时,我的分块大小恰好为17179870字节。 - undefined

13

这是AWS挑战谜题中的又一环。

值得一提的是,本答案假设您已经知道如何计算“MD5 of MD5 parts”,并可以从此前提供的所有其他答案中重建AWS多部分ETag。

本答案解决的是不得不“猜测”或以其他方式“推导出”原始上传部件大小的烦恼。

我们使用多个不同的工具上传到S3,它们似乎都有不同的上传部件大小,因此“猜测”真的不是一个选项。 此外,我们有许多文件是在历史上上传的,当时部件大小似乎是不同的。 另外,使用内部服务器复制来强制创建MD5类型的ETag的老技巧也不再起作用,因为AWS已经将其内部服务器复制更改为使用多部分(仅使用相当大的部件大小)。

那么... 如何确定对象的部件大小?

首先,您需要发送一个 head_object 请求并检测 ETag 是否是多部分类型的 ETag(在末尾包含 '-<partcount>'),然后您可以再次发送一个带有 part_number 属性为1(第一部分)的 head_object 请求。此后续 head_object 请求将返回第一部分的 content_length。 然后,您就知道了使用的部件大小,并且可以使用该大小重新创建本地 ETag,该 ETag 应与上传对象时创建的原始 S3 ETag 相匹配。

此外,如果您想要精确(也许某些多部分上传将使用不同的部分大小),则可以继续调用指定每个 part_number 的 head_object 请求,并从返回的部分 content_length 计算出每个部分的 MD5。

希望这可以帮到您...


3
注意:最近我不得不更新我的代码,以遵循我在最后一段中的建议。我们遇到了一个拥有多种不同零件尺寸的对象!真是让人难以置信! - Hans

12

Bash实现

Python实现

该算法的步骤如下(来自Python实现中的readme):

  1. 对块进行MD5哈希。
  2. 将所有块的哈希字符串合并在一起。
  3. 将合并后的字符串转换为二进制。
  4. 对二进制合并块哈希值的结果进行MD5哈希。
  5. 将"-块数"附加到二进制的MD5哈希字符串末尾。

这并没有真正解释算法的工作原理,等等。(顺便说一下,我没有减1) - Willem Van Onsem
我将实际算法以逐步列表的形式添加了进去。我编写了Python实现,整天浏览如何完成它的帖子,其中大部分都充满了不正确或过时的信息。 - tlastowka
2
这似乎不起作用。使用默认的块大小为8(MB),我得到了与亚马逊告诉我的正确ETag不同的结果。 - Cory
@Cory 我不能代表bash脚本发言,但是Python实现在文件大小小于8MB的块大小时存在问题。不过已经有一个拉取请求来修正这个问题。 - v.tralala
我花了很长时间,但这个Python版本对我有用,块大小为16(MB),我认为这可能是新的默认块大小。 - SerialEnabler

10

同样的算法,Java版本: (BaseEncoding、Hasher、Hashing等来自guava库

/**
 * Generate checksum for object came from multipart upload</p>
 * </p>
 * AWS S3 spec: Entity tag that identifies the newly created object's data. Objects with different object data will have different entity tags. The entity tag is an opaque string. The entity tag may or may not be an MD5 digest of the object data. If the entity tag is not an MD5 digest of the object data, it will contain one or more nonhexadecimal characters and/or will consist of less than 32 or more than 32 hexadecimal digits.</p> 
 * Algorithm follows AWS S3 implementation: https://github.com/Teachnova/s3md5</p>
 */
private static String calculateChecksumForMultipartUpload(List<String> md5s) {      
    StringBuilder stringBuilder = new StringBuilder();
    for (String md5:md5s) {
        stringBuilder.append(md5);
    }

    String hex = stringBuilder.toString();
    byte raw[] = BaseEncoding.base16().decode(hex.toUpperCase());
    Hasher hasher = Hashing.md5().newHasher();
    hasher.putBytes(raw);
    String digest = hasher.hash().toString();

    return digest + "-" + md5s.size();
}

我的天啊!!!!!!我花了很多很多小时来尝试正确地进行二进制编码...我不知道guava有这个功能。 - Nicholas Terry
非常好,运行得很顺畅。只是需要注意:如果需要,您可以使用来自apache-commons的单行代码DigestUtils.md5Hex(raw)代替Guava Hasher。 - Pom12
@Pom12,请问您能将这个函数转换成TypeScript吗? - JIGNESH PATEL

10

不确定这是否有帮助:

我们目前正在进行一种丑陋的(但迄今为止有用的)黑客行为,以修复多部分上传文件中的这些错误ETags,其包括对存储桶中的文件进行更改;这会触发亚马逊的md5重新计算,从而更改ETag以与实际的md5签名匹配。

在我们的情况下:

文件:bucket/Foo.mpg.gpg

  1. 获得的ETag为:“3f92dffef0a11d175e60fb8b958b4e6e-2”
  2. 对文件进行某些操作重命名、添加类似于虚假标题的元数据等)
  3. 获得的ETag为:“c1d903ca1bb6dc68778ef21e74cc15b0”

我们不知道算法,但由于我们可以“修复”ETag,因此我们不需要担心它。


2
它无法处理大于5GB的文件 :( 你有解决方法吗? - d33pika
似乎这个已经停止工作了,至少对于我正在检查的文件是这样。 - phunehehe
我也发现了这个技巧,试图理解为什么通过Web界面上传的文件的Etags突然没有按预期计算。而且在2019年,这仍然有效并起到了作用。有任何想法为什么会这样,并且仍然是这种情况吗? - dletozeun
无论如何,依赖Etag来比较文件似乎不是一个好主意(除了计算时间长之外),因为该算法没有文档记录,而且会不时出现问题。实际上,S3系统元数据似乎包含文件的MD5值(https://docs.aws.amazon.com/AmazonS3/latest/dev/UsingMetadata.html#object-metadata),这可能会回答最初的问题。但我还没有测试过检索此元数据。 - dletozeun

7
根据AWS文档,ETag不是多部分上传的MD5哈希值,也不是加密对象的MD5哈希值:http://docs.aws.amazon.com/AmazonS3/latest/API/RESTCommonResponseHeaders.html 通过PUT Object、POST Object或Copy操作创建的对象,或通过AWS管理控制台创建的对象,并使用SSE-S3或明文加密,具有其对象数据的MD5摘要作为ETag。
通过PUT Object、POST Object或Copy操作创建的对象,或通过AWS管理控制台创建的对象,并使用SSE-C或SSE-KMS加密,其ETag不是其对象数据的MD5摘要。
如果对象是通过多部分上传或部分复制操作创建的,则无论加密方法如何,ETag都不是MD5摘要。

5

Node.js实现 -

const fs = require('fs');
const crypto = require('crypto');

const chunk = 1024 * 1024 * 5; // 5MB

const md5 = data => crypto.createHash('md5').update(data).digest('hex');

const getEtagOfFile = (filePath) => {
  const stream = fs.readFileSync(filePath);
  if (stream.length <= chunk) {
    return md5(stream);
  }
  const md5Chunks = [];
  const chunksNumber = Math.ceil(stream.length / chunk);
  for (let i = 0; i < chunksNumber; i++) {
    const chunkStream = stream.slice(i * chunk, (i + 1) * chunk);
    md5Chunks.push(md5(chunkStream));
  }

  return `${md5(Buffer.from(md5Chunks.join(''), 'hex'))}-${chunksNumber}`;
};


2
这个算法在文件大小恰好等于一个块的大小时,与S3的行为不完全相同。但这可能取决于上传工具的操作方式。 - bernardn
感谢@bernardn指出这个问题 - 我的库刚刚出现了一个问题,是AWS最近更改了吗?https://github.com/pyramation/etag-hash/issues/1 - pyramation
如果AWS最近有所更改,我相信这个解决方案现在对于1个块是正确的,而以前可能是不正确的。然而,在更新库之前,我正在尽职调查以确保它已经正式更改。 - pyramation
@pyramation 我重新测试了我的工具在这里,我认为AWS的实现没有任何变化,因为我的测试仍然成功。可能发生变化的是文件上传的方式,无论是通过Web界面还是aws-cli。 - bernardn
我采用了一种不同的方法,似乎与原始的Bash实现相匹配:https://dev59.com/Cmct5IYBdhLWcg3wULzo#70375683 - badsyntax

5
在上面的回答中,有人问是否有一种方法可以获取大于5G的文件的md5值。
我可以给出一个获取MD5值的答案(适用于大于5G的文件),即手动将其添加到元数据中,或使用程序进行上传并添加信息。
例如,我使用s3cmd上传了一个文件,并添加了以下元数据。
$ aws s3api head-object --bucket xxxxxxx --key noarch/epel-release-6-8.noarch.rpm 
{
  "AcceptRanges": "bytes", 
  "ContentType": "binary/octet-stream", 
  "LastModified": "Sat, 19 Sep 2015 03:27:25 GMT", 
  "ContentLength": 14540, 
  "ETag": "\"2cd0ae668a585a14e07c2ea4f264d79b\"", 
  "Metadata": {
    "s3cmd-attrs": "uid:502/gname:staff/uname:xxxxxx/gid:20/mode:33188/mtime:1352129496/atime:1441758431/md5:2cd0ae668a585a14e07c2ea4f264d79b/ctime:1441385182"
  }
}

虽然不能直接使用ETag解决问题,但是有一种方法可以以可访问的方式填充所需的元数据(MD5)。但如果某人上传不带元数据的文件,该方法仍将失败。


3

以下是Ruby语言中的算法...

require 'digest'

# PART_SIZE should match the chosen part size of the multipart upload
# Set here as 10MB
PART_SIZE = 1024*1024*10 

class File
  def each_part(part_size = PART_SIZE)
    yield read(part_size) until eof?
  end
end

file = File.new('<path_to_file>')

hashes = []

file.each_part do |part|
  hashes << Digest::MD5.hexdigest(part)
end

multipart_hash = Digest::MD5.hexdigest([hashes.join].pack('H*'))
multipart_etag = "#{multipart_hash}-#{hashes.count}"

感谢 Ruby中最短的Hex2Bin方法使用AWS SDK V2实现S3的多部分上传 ...


不错!我确认这对我有效。小改动:最后一个“multi_part_hash”应该改为“multipart_hash”。我还在主要部分周围添加了一个“ARGV.each do”循环,并在结尾处添加了一个打印语句,使其成为一个命令行脚本。 - William Pietri

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接