S3へコピーしたデータが、本当にコピー元と同一かどうか検証してみた時のお話

# 1. はじめに

昨年末、自宅のPCに古い細々したデータがたまってきたので最終更新日時が一定期間経過したデータをAWSのクラウドスレージサービスS3へアーカイブするスクリプトを **PowerShell** で書いていました。

単純にコピーするだけであれば、AWSで提供している「AWS Tools for Windows PowerShell」の **Write-S3Object**コマンドを使えば簡単に実行できます。
ただし、S3にコピーしたからといって、ローカルのデータは消すには勇気がいります。**「本当にコピー元と先で同一なんだよね？」**って若干不安になりました。

そこで、**単にS3へコピーしてローカルのデータを削除してしまうのではなく、コピーしたらコピー元と先で同一であるかを検証する処理を加えることにしました**。
検証するに当たり、S3の仕様について色々と気づきがありましたので、その内容を共有いたします。

# 2. 必要な準備
### 2.1. AWS Tools for Windows PowerShell のインストール
　以下のページからAWS Toolsをダウンロードして、インストールします。
　https://aws.amazon.com/jp/powershell/

　詳細なセットアップ手順や前提条件は以下のページに記載されています。
　https://docs.aws.amazon.com/ja_jp/powershell/latest/userguide/pstools-getting-set-up-windows.html#prerequisites

### 2.2. プロファイルの作成

　上記のAWS Toolsをインストールしたら、AWSアカウントのアクセスキーとシークレットキーを **Set-AWSCredential** コマンドで登録し、プロファイルを作成します。

　https://docs.aws.amazon.com/ja_jp/powershell/latest/userguide/specifying-your-aws-credentials.html

```
# 例1）プロファイルの登録
PS C:\> Set-AWSCredential -AccessKey アクセスキー -SecretKey シークレットキー -StoreAs プロファイル名
```

これで、必要な準備は完了です。


# 3. S3の仕様
### 3.1. マルチパート・アップロード機能について
PowerShellでS3へアップロードする場合、**Write-S3Object** コマンドを利用します。
**Write-S3Object** によるアップロードは、16MB以上のデータが対象となる場合強制的に**マルチパート・アップロード**が実行されます。
マルチパート・アップロードは、ローカルデータを5MBのブロックサイズに区切り、S3へ並列でアップロードする処理を指します。
デフォルトでは、10パート並列でアップロードが実行されます。並列での同時アップロード数は **-ConcurrentServiceRequest** サブコマンドで制御することもできるので、簡易的な帯域制御が可能です。
（ netstatコマンドで確認したところ、確かに10セッションがS3に対して確立されていました。）


### 3.2. S3オブジェクトのeTagプロパティについて
S3へアップロードしたデータの完全性（コピー元と先で同一であるか）を検証するため、調査していたら、**S3のeTagプロパティがローカルデータのMD5ダイジェストと一致する**ことに気づきました。
（公式にもMD5ダイジェストを利用している記述がありました。）
これを比較すれば、ローカルデータがS3上に完全な状態でコピーされたことを確認できると思ったのですが、、**マルチパート・アップロードが行われる際はうまく適用できませんでした**。
というのも、S3のeTagはマルチパート・アップロードの有無によって、以下のロジックを使い分けて、eTagを計算しているからです。

##### i.  マルチパート・アップロード無（16MBよりも小さいデータ）のeTag
　ローカルデータのMD5ダイジェストと一致
　例）10MBのデータ
　S3のeTagプロパティ：cd573cfaace07e7949bc0c46028904ff
　ローカルデータのMD5：cd573cfaace07e7949bc0c46028904ff

##### ii. マルチパート・アップロード有（16MB以上のデータ）のeTag
　①データをバイナリ形式で読み取る
　②パート毎に区切り、それぞれのMD5ダイジェストを取得する
　  ※S3のパートサイズは5MB
　③パート毎のMD5ダイジェストを結合する
　④結合したMD5ダイジェストのMD5ダイジェストを取得する
　⑤マルチパートの総パート数をハイフン付きで④の末尾に付加する

　例）1GBのデータ
　S3のeTagプロパティ：cb45770d6cf51effdfb2ea35322459c3-205


# 4. 完全性の検証
### 4.1. 検証の方針
**ファイルサイズ（16MBが閾値）に応じて、S3のeTagの値をローカルデータから計算し、実際のeTagプロパティと比較**することにしました。
マルチパート・アップロード有（16MB以上のデータ）のeTag計算は、以下のコードを参考にしました。（計算ロジックに若干の修正をしています。）

- Calculate Amazon eTag hash for named file. Powershell
  https://gist.github.com/seanbamforth/9388507


### 4.2. 検証の結果
fsutil コマンドで作成した15MB、16MB、17MBのダミーファイルや大容量ファイルで検証してみましたが、いずれも**ローカルデータから計算したS3のeTag値と実際のeTagプロパティが一致していることを確認できました**。
以上をもって、「コピー元と先で同一であること」を確認する手順を確立したので、この手順で完全性を確認できた際は、安心してローカルのデータを削除できます。

# 5. あとがき
検証に利用したPowershellコマンド群を「S3のeTag値をローカルデータから計算する関数 **Get-S3ETagHash**」と「完全性を確認してローカルデータを削除するという一連の処理をする関数 **Archive-FileToS3**」としてスクリプトにまとめました。
（前者は、https://gist.github.com/seanbamforth/9388507 を参考にしています）

このスクリプト **Archive-FileToS3.ps1** をインポートすることで、以下のように関数を利用できます。参考までに下に掲載しておきます。

```
# 例2）スクリプトのインポートと関数の利用方法
PS D:\Tmp> Import-Module .\Archive-FileToS3.ps1

PS D:\Tmp> Get-S3ETagHash -Path D:\Tmp\16MB.txt

Algorithm Hash                               Path           
--------- ----                               ----           
S3ETag    eafa449afe224ad0b7f8f5bab4145d13-4 D:\Tmp\16MB.txt


PS D:\Tmp> Archive-FileToS3 -Path アーカイブ対象フォルダ -Bucketname バケット名 -Days 期間
```


##### [参考]Powershell スクリプト（Archive-FileToS3.ps1）

```powershell:Archive-FileToS3.ps1
# Archive-FileToS3 archives local files which is older than specified days in a path to S3bucket with verifying integrity of uploaded S3objects.
# The verification method is to check if an eTag of S3object matches an eTag value calculated from a local file.

# Get-S3ETagHash calculates an eTag for a local file that should match the S3 eTag of the uploaded file. 
# Credit goes to Sean Bamforth (https://gist.github.com/seanbamforth/9388507) and chrisdarth(https://gist.github.com/chrisdarth/02d030b31727d70d2c63)

function Archive-FileToS3 {
    [cmdletbinding()]
    Param (
        [Parameter(Mandatory=$true)]
        [ValidateScript({ Test-Path $_ -PathType Container })]
        [string]$Path,
        [Parameter(Mandatory=$true)]
        [ValidateScript({ $( Get-S3Bucket -BucketName $_ ) })]
        [string]$Bucketname,
        [Parameter(Mandatory=$true)]
        [Int32]$Days,
        [Int32]$ConcurrentServiceRequest = 10
    )
    if ($Path[$Path.Length-1] -eq "\" ){ $Path = $Path.Substring(0,$Path.Length-1) }
    foreach ($file in $(Get-ChildItem $Path -Recurse | Where-Object{$_.Attributes -ne "directory"})){
        if ($file.LastWriteTime -lt ((Get-Date).AddDays(-$Days))){
            Write-S3Object -BucketName $Bucketname -Key $file.FullName.Substring($Path.Length + 1) -File $file.FullName -ConcurrentServiceRequest $ConcurrentServiceRequest
            $s3object = Get-S3Object -BucketName $Bucketname -Key $file.FullName.Substring($Path.Length + 1)
            $etag = $s3object.etag.Replace("`"","")
            $hash = $(Get-S3ETagHash($file.FullName)).Hash
            if ($etag -eq $hash){
                Remove-Item $file.FullName -Force
            }else{
                Remove-S3Object -BucketName $Bucketname -Key $file.FullName.Substring($Path.Length + 1) -Force
            }
        }
    }
}

function Get-S3ETagHash {
    [cmdletbinding()]
    Param (
        [Parameter(Mandatory=$true)]
        [ValidateScript({ Test-Path $_ -PathType Leaf })]
        [string]$Path,
        [Int32]$ChunkSize = 5
    )
    $filename = Get-Item $Path
    $md5 = new-object -TypeName System.Security.Cryptography.MD5CryptoServiceProvider

    $blocksize = (1024*1024*$ChunkSize)
    $startblocks = (1024*1024*16)
    
    $lines = 0
    [byte[]] $binHash = @()
 
    $reader = [System.IO.File]::Open($filename,"OPEN","READ")
    
    if ($filename.length -ge $startblocks) {
        $buf = new-object byte[] $blocksize
        while (($read_len = $reader.Read($buf,0,$buf.length)) -ne 0){
            $lines   += 1
            $binHash += $md5.ComputeHash($buf,0,$read_len)
        }
        $binHash=$md5.ComputeHash( $binHash )
    }
    else {
        $lines   = 1
        $binHash += $md5.ComputeHash($reader)
    }
 
    $reader.Close()
    
    $hash = [System.BitConverter]::ToString( $binHash )
    $hash = $hash.Replace("-","").ToLower()
 
    if ($lines -gt 1) {
        $hash = $hash + "-$lines"
    }
    
    # Output pscustomobject, equal to Get-FileHash
    [pscustomobject]@{
        Algorithm = "S3ETag"
        Hash =$hash
        Path = $filename.FullName
    }
    return
}
```