S3 にマルチパートアップロードしたファイルの ETag と SHA-256 を確認してみた
はじめに
Amazon S3 に保管したファイルが、アップロード元のファイルと同じか確認したいことがあります。このとき、 ETag や ChecksumSHA256 をローカルで計算したハッシュ値と比較したくなります。
しかし、マルチパートアップロードされたオブジェクトでは、どちらもファイル全体のハッシュ値とは限りません。 実際に大容量ファイルを S3 へ保存した際、 head-object で取得した ETag と ChecksumSHA256 には、いずれもパート数を示す接尾辞が付いていました。
本記事では、同じ 20 MiB の合成ファイルを単一 PUT とマルチパートアップロードで S3 へ保存し、結果を比較しました。さらに、取得したオブジェクトの SHA-256 を再計算し、アップロード元と同じファイルであることを確認しました。
対象読者
- 大容量ファイルを S3 へ保存している方
- S3 の
ETagやChecksumSHA256を完全性確認へ使いたい方 - マルチパートアップロード後に残すべき証跡を検討している方
検証環境
| 項目 | 値 |
|---|---|
| OS | Windows 11 |
| AWS CLI | 2.31.8 |
| AWS リージョン | us-east-1 |
| S3 バケット | バージョニング有効、SSE-S3 |
| 入力ファイル | 20 MiB、全バイトが 0x00 の合成ファイル |
| マルチパートのパートサイズ | 8,388,608 bytes |
AWS CLI の aws s3 コマンドでは、 multipart_threshold と multipart_chunksize の既定値が 8 MB です。今回は設定を上書きせず、20 MiB のファイルが 3 パートに分割される条件で検証しました。
参考資料
- AWS: Checking object integrity for data uploads in Amazon S3
- AWS: Uploading and copying objects using multipart upload
- AWS CLI: S3 Configuration
- AWS CLI: head-object
- AWS CLI: S3 FAQ
検証方法
ひとつの入力ファイルを、 put-object による単一 PUT と、 s3 cp によるマルチパートアップロードで別々のキーに保存します。 --checksum-algorithm SHA256 は両方に指定します。
合成ファイルを作成する
検証用に全バイトが 0x00 のファイルを作成しました。
fsutil file createnew .\source-20m.bin 20971520
Get-FileHash .\source-20m.bin -Algorithm SHA256
Get-FileHash .\source-20m.bin -Algorithm MD5
計算結果は次のとおりです。
| アルゴリズム | 値 |
|---|---|
| SHA-256 | cd52d81e25f372e6fa4db2c0dfceb59862c1969cab17096da352b34950c973cc |
| MD5 | 8f4e33f3dc3e414ff94e5fb6905cba8c |
MD5 は安全性の確認には使いません。今回は、SSE-S3 を使用した単一 PUT の ETag と、マルチパートの ETag 計算を比較するために求めています。
同じファイルを 2 通りでアップロードする
aws s3api put-object `
--bucket <bucket-name> `
--key article-evidence/singlepart-20m.bin `
--body .\source-20m.bin `
--checksum-algorithm SHA256
aws s3 cp `
.\source-20m.bin `
s3://<bucket-name>/article-evidence/multipart-20m.bin `
--checksum-algorithm SHA256
アップロード後は、 --checksum-mode ENABLED を付けてオブジェクトの属性を取得しました。
aws s3api head-object `
--bucket <bucket-name> `
--key article-evidence/multipart-20m.bin `
--checksum-mode ENABLED
実行結果
同じ内容のファイルでも、アップロード方法によって結果が変わりました。
| 項目 | 単一 PUT | マルチパート |
|---|---|---|
ContentLength |
20971520 |
20971520 |
ChecksumType |
FULL_OBJECT |
COMPOSITE |
ChecksumSHA256 |
zVLYHiXzcub6TbLA3861mGLBlpyrFwlto1KzSVDJc8w= |
7zmHyb00nwkYLqwFgYQ2jBrMWehLGRYkk+0H2X/ZOYk=-3 |
ETag |
8f4e33f3dc3e414ff94e5fb6905cba8c |
5452e5568d20a60209babc69a7b95911-3 |
単一 PUT の ChecksumSHA256 は、ファイル全体の SHA-256 を Base64 で表した値です。ローカルの Get-FileHash は同じダイジェストを 16 進数で表示するため、見た目は異なります。
単一 PUT の ETag は、今回の SSE-S3 という条件ではローカルの MD5 と一致しました。ただし、 ETag は一般に MD5 として扱える値ではありません。暗号化方式やアップロード方法などの条件によって挙動が異なります。
マルチパート側では、 ChecksumType が COMPOSITE になりました。 ChecksumSHA256 と ETag の末尾に付いた -3 は、今回のオブジェクトが 3 パートで構成されていることを示します。どちらもローカルで計算したファイル全体の SHA-256 や MD5 とは一致しません。
COMPOSITE チェックサムを再計算する
8 MiB、8 MiB、4 MiB に分割した各パートのダイジェストから、S3 が返した値を再現しました。文字列として連結するのではなく、各ダイジェストのバイト列を連結してから、もう一度ハッシュを計算します。
ETag = Hex(MD5(MD5(part1) || MD5(part2) || MD5(part3))) + "-3"
ChecksumSHA256 = Base64(
SHA256(SHA256(part1) || SHA256(part2) || SHA256(part3))
) + "-3"
再計算した結果は、 head-object で取得した値と一致しました。
| 項目 | 再計算した値 | S3 の値 |
|---|---|---|
multipart ETag |
5452e5568d20a60209babc69a7b95911-3 |
一致 |
| composite SHA-256 | 7zmHyb00nwkYLqwFgYQ2jBrMWehLGRYkk+0H2X/ZOYk=-3 |
一致 |
今回の COMPOSITE チェックサムでは、最終的な SHA-256 の入力は、3 個の SHA-256 ダイジェストを連結した 96 bytes でした。
ファイル全体の SHA-256 の確認
マルチパートの ChecksumSHA256 は、各パートの SHA-256 を組み合わせた値です。配布元が提示したファイル全体の SHA-256 と、 head-object の値を直接比較する用途には使えません。
今回は 2 オブジェクトを get-object --checksum-mode ENABLED で取得しました。AWS CLI は取得時に S3 のチェックサムを検証します。そのうえで、取得したファイル全体の SHA-256 をローカルで再計算しました。
aws s3api get-object `
--bucket <bucket-name> `
--key article-evidence/multipart-20m.bin `
--checksum-mode ENABLED `
.\downloaded-multipart-20m.bin
Get-FileHash .\downloaded-multipart-20m.bin -Algorithm SHA256
| ファイル | 完全 SHA-256 |
|---|---|
| アップロード元 | cd52d81e25f372e6fa4db2c0dfceb59862c1969cab17096da352b34950c973cc |
| 単一 PUT から取得 | 同じ |
| マルチパートから取得 | 同じ |
単一 PUT とマルチパートから取得したファイルは、どちらもアップロード元と同じ完全 SHA-256 になりました。一方、マルチパートの ChecksumSHA256 は完全 SHA-256 と一致しませんでした。
AWS 公式仕様との照合
マルチパートアップロードでは、各パートを独立して送信でき、失敗したパートだけを再送できます。S3 は各パートを受信した際にチェックサムを検証し、完了時には保存済みのパート別チェックサムから COMPOSITE チェックサムを計算します。
S3 の公式ドキュメントでは、パート別チェックサムから計算することで、チェックサムの計算コストを抑えられると説明されています。また、マルチパートアップロードでファイル全体のチェックサムを計算できるのは CRC-64/NVME、CRC-32、CRC-32C です。CRC 系のチェックサムはパート別の値からファイル全体の値を計算できますが、SHA-256 と MD5 では同じ計算ができません。そのため、COMPOSITE チェックサムから完全 SHA-256 を復元することもできません。完全 SHA-256 が必要な場合は、ファイル全体から再計算する必要があります。
同ドキュメントでは、マルチパートで保存したオブジェクトを単一のコピー操作で複製すると、データが同じでもチェックサムが変わる場合があることも説明されています。
考察
設計上のトレードオフ
S3 が COMPOSITE チェックサムを使用する理由は、マルチパートアップロードの独立性を保ったまま、完了時のチェックサム計算を軽くするためだと考えられます。オブジェクトが大きくなるほど、ファイル全体を処理する方法と、保存済みのパート別チェックサムを処理する方法の差も大きくなると考えられます。
その代わり、COMPOSITE チェックサムにはファイルの内容だけでなく、パートの分割方法も反映されます。これはファイル内容だけを識別することより、マルチパート転送の効率と完全性確認を優先した設計だと考えられます。
ファイル識別への影響
この設計を踏まえると、ETag や COMPOSITE チェックサムをファイル固有の識別子として使うことは避けた方がよいと考えられます。アップロードツール、パートサイズ、コピー方法が変わるだけで値が変化し、重複排除やアーティファクト比較で同じ内容を別のファイルと判定する可能性があるためです。配布元の SHA-256 がある場合はアップロード前に照合し、その値を別途保存しておく方法が適切だと考えられます。
以上から、完全 SHA-256、S3 のチェックサム、 VersionId は用途別に管理するとよいでしょう。
| 用途 | 使用する値 |
|---|---|
| 配布元との一致、重複排除、アーティファクト比較 | 完全 SHA-256 |
| S3 との転送時における完全性の確認 | ChecksumType と S3 のチェックサム |
| S3 上の特定バージョンの指定 | VersionId |
まとめ
同じ 20 MiB のファイルを単一 PUT とマルチパートアップロードで S3 へ保存し、 ETag と SHA-256 チェックサムを比較しました。マルチパートでは両方がパート構成を反映した値になり、ファイル全体のハッシュとは一致しませんでした。配布元の SHA-256 と照合するときは、 ChecksumType を確認し、完全 SHA-256、 VersionId 、サイズ、S3 のチェックサムを別の証跡として扱う必要があります。S3 に大容量ファイルを保存する際の完全性確認に、本記事が参考になれば幸いです。
付録
COMPOSITE チェックサムの再計算に使用したコードを掲載します。
ETag と COMPOSITE SHA-256 の計算スクリプト
from __future__ import annotations
import argparse
import base64
import hashlib
import json
from pathlib import Path
def calculate(path: Path, part_size: int) -> dict[str, object]:
full_md5 = hashlib.md5()
full_sha256 = hashlib.sha256()
part_md5_digests: list[bytes] = []
part_sha256_digests: list[bytes] = []
with path.open("rb") as source:
while chunk := source.read(part_size):
full_md5.update(chunk)
full_sha256.update(chunk)
part_md5_digests.append(hashlib.md5(chunk).digest())
part_sha256_digests.append(hashlib.sha256(chunk).digest())
part_count = len(part_md5_digests)
composite_md5 = hashlib.md5(b"".join(part_md5_digests)).hexdigest()
composite_sha256 = hashlib.sha256(b"".join(part_sha256_digests)).digest()
return {
"bytes": path.stat().st_size,
"part_size": part_size,
"part_count": part_count,
"full_md5_hex": full_md5.hexdigest(),
"full_sha256_hex": full_sha256.hexdigest(),
"full_sha256_base64": base64.b64encode(full_sha256.digest()).decode("ascii"),
"multipart_etag": f"{composite_md5}-{part_count}",
"composite_sha256": (
f"{base64.b64encode(composite_sha256).decode('ascii')}-{part_count}"
),
}
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("path", type=Path)
parser.add_argument("--part-size", type=int, default=8 * 1024 * 1024)
args = parser.parse_args()
print(json.dumps(calculate(args.path, args.part_size), indent=2))
if __name__ == "__main__":
main()






