Windows
AWS
S3
PowerShell

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計算は、以下のコードを参考にしました。(計算ロジックに若干の修正をしています。)


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)


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
}