S3バージョニングのおさらいと大量オブジェクトクリーンアップスクリプト

![001samune.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3984127/4532fd0d-372a-4c30-9d66-6b6582044f74.png)


バージョニングが有効なバケット内の大量オブジェクト削除スクリプトを作成したので、バージョニングについてのおさらい含めて記事にしました。

# 背景
ある時「このS3バケット使わないから削除したい」と思ったのでまずはバケットを空にしようと `aws s3 rm s3://bucket-name --recursive` で一掃。その後バケットを削除しようとするとエラー。まさかと思いマネコンを除くとバージョニングが有効化されていました。
だったらと思い以下のコマンドを実行すると何やら別のエラーが。

```
[cloudshell-user@ip-10-134-9-232 ~]$ aws s3api delete-objects --bucket bucket-name --delete "$(aws s3api list-object-versions --bucket bucket-name | jq '.Versions + .DeleteMarkers | {Objects: map({Key, VersionId})}')"
bash: /usr/local/bin/aws: Argument list too long
```

上記エラーは1000件よりも多いオブジェクトを削除しようとしたときに発生します。(後述)
そのため、1000件よりも多い場合は、1000件ずつ区切って削除していくしかありません。
そこで今回、対象のバケットのオブジェクト数から1000件ずつ(1000件以内の場合はその数)で削除するスクリプトを作成しました。加えて、特定のプレフィックスのみを対象にできるようにもしてみました。

本記事では、スクリプトの紹介だけでは寂しいのでバージョニングについてもおさらいしようと思います。

# バージョニングについておさらい
### バージョニングとは
バージョニングとは、文字通りバケット内の全てのオブジェクトに一意のバージョンIDを付与し保持する機能のことです。
オブジェクトの誤削除や上書きをしてしまっても、バージョン管理しているため簡単に復元することが可能です。
そのため、監査ログなどの監査データやシステム上重要なデータを保管するバケットには有効な機能といえます。

### バージョニングの状態遷移
バージョニングはバケット単位でのみ有効化することが可能です。デフォルトでは無効となっています。
有効化した場合、停止することは可能ですが無効化することはできません。
![画像1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3984127/bc71d646-c78c-47c4-cc7a-bdf6ec595cdb.png)

### バージョニング有効時のオブジェクトの挙動
前述しましたが、バージョニングが有効なバケットにオブジェクトをアップロード(PutObject)すると、そのオブジェクトに対し一意のバージョンIDが付与されます。同一のオブジェクトをアップロードするたびに新たなバージョンIDが付与され、上書きされることなく全てのオブジェクトが保管されます。
![画像7.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3984127/e1cb51fc-477f-5cba-c213-0194e0425b56.png)

現行のバージョンのオブジェクトを削除すると、削除マーカー(Delete marker)が最新のバージョン(現行のバージョン)として挿入されます。つまり論理削除がされます。
![画像8.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3984127/43ff1969-d1f5-2ace-5af3-c064a5776cae.png)

また、再度アップロードすると、削除マーカーが以前のバージョンになり、アップロードしたオブジェクトが現行のバージョンとなります。
![画像9.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3984127/7104e2d2-f9e9-5b8c-a4af-ac4138da5cd0.png)

:::note info
無効、停止状態でオブジェクトをPut.Deleteした場合のバージョンIDはnull
:::

### 特定バージョンのオブジェクト復元は二通り
* 復元したいオブジェクトよりバージョンが新しいオブジェクトを削除する
![画像11.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3984127/b2eafa02-76a0-f44d-f9f8-4a3b891976f3.png)

* 復元したいオブジェクトをコピーする
![画像12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3984127/870c61b5-d21d-4711-31d3-23e11375c283.png)
:::note info
上記の場合、CLIだと以下コマンドで可能です。
```
aws s3api copy-object \
    --bucket bucket-name \
    --key test.png \
    --copy-source bucket-name/test.png?versionId=21 \
    --metadata-directive COPY
```
:::


### 料金のかかり方に注意
バージョニングを有効している場合、バージョニングされたオブジェクトすべてに料金がかかります。
例えば以下の状況で考えてみます。
![画像5.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3984127/ae83a1bc-25d0-83ef-d4b3-5f0df9704013.png)

この場合の簡易的な計算は以下となります。(※1か月を31日とします)
* 単位月当たりの総保管量
```math
((1GB \times 31日)+(5GB \times 16日)) \times \frac{1}{31} \fallingdotseq 3.581 GB/月
```
* 月料金(東京リージョン)
```math
3.581GB/月 \times 0.025USD/GB \fallingdotseq 0.09USD/月
```

:::note info
より詳細な計算式は公式のFAQに記載されているため気になる方はご覧ください。
:::

https://aws.amazon.com/jp/s3/faqs/


# 本題
### 1000以上のオブジェクトをCLIで削除しようとすると制限に引っかかる
実はコマンドリファレンスに書かれています。
> The request can contain a list of up to 1000 keys that you want to delete.

仕様である以上仕方ないので、1000件ずつ区切って削除するようにシェルスクリプトを作成しました。
加えて特定のプレフィックス配下のオブジェクトを対象にできるようにも工夫しています。

### 制限を気にしないわがままなスクリプトがこちら
実際に作成したシェルスクリプトがこちらです。
```sh:delete-objects.sh
#!/bin/bash

# 引数でバケット名を取得
if [[ -z "$1" ]]; then
  echo "Usage: $0 <bucket-name>"
  exit 1
fi

BUCKET_NAME="$1"

# 一時ファイルを作成
TMP_FILE=$(mktemp)

# オブジェクトのバージョンリストを取得
LIST_OUTPUT=$(aws s3api list-object-versions --bucket "$BUCKET_NAME")

# リスト取得の成功確認
if [[ $? -ne 0 ]]; then
  echo "Failed to retrieve object versions. Please check your bucket name and permissions."
  rm -f "$TMP_FILE"
  exit 1
fi

# プレフィックス（階層ごとにすべて）を抽出
echo "$LIST_OUTPUT" | jq -r '.Versions[].Key' | awk -F'/' '{
  prefix = "";
  for (i = 1; i < NF; i++) {
    prefix = (prefix ? prefix "/" : "") $i;
    print prefix "/";
  }
}' | sort | uniq > "$TMP_FILE"

# プレフィックスリストに "すべて削除" を追加
echo "Available prefixes:"
cat -n "$TMP_FILE"
echo "$(($(wc -l < "$TMP_FILE") + 1))) Delete all objects in the bucket"

# ユーザーに選択を求める
echo "Enter the number of the option to proceed:"
read -r OPTION_SELECTION

# ユーザーの選択を処理
if [[ -n "$OPTION_SELECTION" ]]; then
  TOTAL_OPTIONS=$(wc -l < "$TMP_FILE")
  if [[ "$OPTION_SELECTION" -eq "$((TOTAL_OPTIONS + 1))" ]]; then
    echo "You selected to delete all objects in the bucket."
    SELECTED_PREFIX=""
  else
    SELECTED_PREFIX=$(sed -n "${OPTION_SELECTION}p" "$TMP_FILE")
    if [[ -z "$SELECTED_PREFIX" ]]; then
      echo "Invalid selection. Exiting."
      rm -f "$TMP_FILE"
      exit 1
    fi
    echo "Selected prefix: $SELECTED_PREFIX"
  fi
else
  echo "No option selected. Exiting."
  rm -f "$TMP_FILE"
  exit 1
fi

# 削除対象オブジェクトリストの取得
if [[ -n "$SELECTED_PREFIX" ]]; then
  LIST_OUTPUT=$(aws s3api list-object-versions --bucket "$BUCKET_NAME" --prefix "$SELECTED_PREFIX")
fi

# jqでオブジェクトリストを安全に整形
echo "$LIST_OUTPUT" | jq -c '.Versions + .DeleteMarkers // [] | map({Key, VersionId})' > objects.json
TOTAL_OBJECTS=$(jq 'length' objects.json)

if [[ "$TOTAL_OBJECTS" -eq 0 ]]; then
  echo "No objects to delete in bucket '$BUCKET_NAME'."
  rm -f "$TMP_FILE" objects.json
  exit 0
fi

echo "Total objects to delete: $TOTAL_OBJECTS"

# APIのレスポンスがページングされて処理が止まらないようにする
export AWS_PAGER=""

# 1000件ずつ削除
BATCH_SIZE=1000
for ((i = 0; i < TOTAL_OBJECTS; i += BATCH_SIZE)); do
  END_INDEX=$((i + BATCH_SIZE))
  if [[ "$END_INDEX" -gt "$TOTAL_OBJECTS" ]]; then
    END_INDEX="$TOTAL_OBJECTS"
  fi

  jq --argjson start "$i" --argjson end "$END_INDEX" '.[$start:$end] | {Objects: .}' objects.json > batch.json
  aws s3api delete-objects --bucket "$BUCKET_NAME" --delete "file://batch.json"
  echo "Deleted objects $((i + 1)) to $END_INDEX"
done

# 一時ファイルを削除
rm -f "$TMP_FILE" objects.json batch.json

echo "Deletion completed for bucket '$BUCKET_NAME'."
```
使用方法及びスクリプトについてはGitHubに公開しています。

https://github.com/tyskJ/aws-s3-versioning-massobject-delete-script

### 実際に1000件以上オブジェクト削除を試してみる
実際に試してみます。対象バケットはバージョニングが有効になっています。
```
[cloudshell-user@ip-10-134-8-81 ~]$ aws s3api get-bucket-versioning --bucket ep002-with-versioning
{
    "Status": "Enabled"
}
```
そして現在の削除マーカー含めたオブジェクトの数は1450あります。
```
[cloudshell-user@ip-10-134-8-81 ~]$ aws s3api list-object-versions --bucket ep002-with-versioning | jq '.Versions + .DeleteMarkers | length'
1450
```

この状態でまずは冒頭で試したCLIコマンドを実行してみます。
```
[cloudshell-user@ip-10-134-8-81 ~]$ aws s3api delete-objects --bucket ep002-with-versioning --delete "$(aws s3api list-object-versions --bucket ep002-with-versioning | jq '.Versions + .DeleteMarkers | {Objects: map({Key, VersionId})}')"
bash: /usr/local/bin/aws: Argument list too long
```

やはりでますね。
それでは作成したスクリプトを実行してみます。特定のプレフィックス配下を削除対象とすることも可能ですが、今回はバケット配下全てのオブジェクトを削除したいので「４」を入力し続行します。
```
[cloudshell-user@ip-10-134-8-81 ~]$ ./delete-objects.sh ep002-with-versioning
Available prefixes:
     1  logs/
     2  logs/prod/
     3  Test/
4) Delete all objects in the bucket
Enter the number of the option to proceed:
4
```

数秒後プロンプトに完了の文字が出力されました。
```
Deletion completed for bucket 'ep002-with-versioning'.
```

この状態で改めてバケットのオブジェクト数を確認すると、見事削除できていますね。
```
[cloudshell-user@ip-10-134-8-81 ~]$ aws s3api list-object-versions --bucket ep002-with-versioning | jq '.Versions + .DeleteMarkers | length'
0
```

# まとめ
今回、バージョニングが有効となっているバケット内の1000個以上の大量オブジェクト一括削除スクリプトを作成しました。正直なところ、ライフサイクルルールを設定すれば片付く話ですが、今すぐ削除したいという方には有効なものかなと思います。
こちらの記事が少しでも誰かの役に立てば幸いです。

# 参考文献
### リファレンス
* [Amazon Simple Storage Service ユーザーガイド - S3バージョニングの仕組み](https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/userguide/versioning-workflows.html)
* [AWS CLI 2.23.2 Command Reference](https://awscli.amazonaws.com/v2/documentation/api/latest/reference/index.html)