S3 にマルチパートアップロードしたファイルの ETag と SHA-256 を確認してみた

S3 にマルチパートアップロードしたファイルの ETag と SHA-256 を確認してみた

Amazon S3 へ同じ 20 MiB のファイルを単一 PUT とマルチパートでアップロードし、ETag と SHA-256 チェックサムを比較しました。COMPOSITE チェックサムをローカルファイルの SHA-256 と直接比較できない理由と、確認時に残す値について紹介します。
2026.07.21

はじめに

Amazon S3 に保管したファイルが、アップロード元のファイルと同じか確認したいことがあります。このとき、 ETagChecksumSHA256 をローカルで計算したハッシュ値と比較したくなります。

しかし、マルチパートアップロードされたオブジェクトでは、どちらもファイル全体のハッシュ値とは限りません。 実際に大容量ファイルを S3 へ保存した際、 head-object で取得した ETagChecksumSHA256 には、いずれもパート数を示す接尾辞が付いていました。

本記事では、同じ 20 MiB の合成ファイルを単一 PUT とマルチパートアップロードで S3 へ保存し、結果を比較しました。さらに、取得したオブジェクトの SHA-256 を再計算し、アップロード元と同じファイルであることを確認しました。

対象読者

  • 大容量ファイルを S3 へ保存している方
  • S3 の ETagChecksumSHA256 を完全性確認へ使いたい方
  • マルチパートアップロード後に残すべき証跡を検討している方

検証環境

項目
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_thresholdmultipart_chunksize の既定値が 8 MB です。今回は設定を上書きせず、20 MiB のファイルが 3 パートに分割される条件で検証しました。

参考資料

検証方法

ひとつの入力ファイルを、 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 として扱える値ではありません。暗号化方式やアップロード方法などの条件によって挙動が異なります。

マルチパート側では、 ChecksumTypeCOMPOSITE になりました。 ChecksumSHA256ETag の末尾に付いた -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 の計算スクリプト
calculate_checksums.py
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()

この記事をシェアする

関連記事