LambdaでS3上のJSON形式の設定ファイルを一括更新する


LambdaでS3上に保管したJSON形式の設定ファイルを一括更新する手順の覚え書きです。

## 要件

- ロボットの設定ファイルを一括更新する
- 設定ファイルの要素は全ファイル同じ
- 要素の増減について、バージョン管理するものとする
- 処理は手動実行のため、コンソールでログが見られれば問題ない

### イメージ

イメージは以下の通り
Version 1.0.0のCountry要素の後ろにRegion要素を空白で追加し、Version 1.1.0に更新する場合

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/356958/0c18dc9c-91b9-2b14-26ec-0db5da808afa.png)



## 手順

### 1. S3バケットとフォルダの作成

まず、S3設定ファイルを保管するバケットとフォルダ（厳密にはオブジェクト）を作成します。


S3バケットの構成は以下の通りで、settingsフォルダ下に設定ファイルを配置する構成。同階層にbackupフォルダも作成し、更新前にバックアップをとることとします。

```:S3の構成
bucket
    |
    |--- settings
            |--- backup
            |--- xxx.json
            |--- yyy.json
            |--- zzz.json
            |--- :
            |--- :
```

(1) [S3](https://s3.console.aws.amazon.com/s3/)にアクセスして、「バケットを作成」をクリック

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/356958/3ce8eb1b-5892-7a86-de35-028d76c8ca37.png)

(2) バケット名を入力し（①）、「バケットを作成」をクリック（②）

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/356958/45c42390-817e-d703-bb74-01d6884817e1.png)

(3) 次にsettingsフォルダを作成します。バケット一覧にて、作成したバケットをクリック
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/356958/4a44ed33-ee7a-f42c-d838-f2dfd90a3b5b.png)

(4) バケット配下に移動したら、「フォルダの作成」をクリック
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/356958/697302b2-bafd-0d12-fb87-cbc2f1a8388d.png)

(5) フォルダの作成ページに移動したら、フォルダ名（settings）を入力し（①）、「フォルダの作成」をクリック（②）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/356958/02b1d5b6-f524-f588-3752-0009d8d489cf.png)

(6) 続いて、backアップフォルダを作成します。settingsフォルダ直下まで移動した後、「フォルダの作成」をクリック
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/356958/50987ae3-efeb-82f7-4e67-cae7010fa8da.png)

(7)  フォルダの作成ページに移動したら、フォルダ名（backup）を入力し（①）、「フォルダの作成」をクリック（②）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/356958/4619d05c-945d-7098-dbcc-663718fcb434.png)

(8) 次のステップで使用するので、バケット名を控えておいてください。
※なお、本記事で作成したバケットは既に削除済です。

### 2. IAMポリシーとIAMロールの作成

続いて、LambdaがS3を操作するために必要なIAMポリシーとIAMロールの作成を行います。

(1) [IAM](https://us-east-1.console.aws.amazon.com/iam/)に移動し、「ポリシー」をクリック（①）、画面遷移したら、「ポリシーの作成」をクリック（②）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/356958/aab424c6-ee21-363b-3de1-133e1cfc2bc2.png)

(2) 「アクセス許可を指定」に遷移したら、サービス（①）、アクセスレベル（②）、リソース（③）に以下の通り入力 or 選択し（※1）、「次へ」をクリック
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/356958/20a109ed-2c92-8e16-ae96-48e87c0ca0d9.png)

※1: サービス、アクセスレベル、リソースの入力

| No |項目名 |入力 or 選択内容  |意味|
|---|---|---|---|
|①  |サービス  |S3  ||
|②  |アクセスレベル（リスト）  | ListBucket |オブジェクト一覧の取得|
|②  |アクセスレベル（読み取り）  | GetObject |オブジェクトの内容を取得|
|②  |アクセスレベル（書き込み）  | PutObject |オブジェクトの作成/更新|
|③  |リソース（特定/bucket）  | 対象バケットのARN |指定バケットを対象とする|
|③  |リソース（特定/object）  | 対象バケットのARN/settings |指定バケットのsettingsフォルダを対象とする|
|③  |リソース（特定/object）  | 対象バケットのARN/settings/*（※2） |対象バケットのsettingsフォルダ下のオブジェクトを対象とする|

→ 最低限必要な権限にしています。

※2: リソース（特定/object）に関しては、少し入力に癖があったので、編集画面のキャプチャも載せておきます

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/356958/bb39c24f-4369-657f-afc0-68b50b6eb876.png)


|No  |項目名  |入力内容  |備考|
|---|---|---|---|
|①  | Resource bucket name |バケット名/settings  |「バケット名」には手順1で作成したバケット名|
|②  | Resource object name |* |「任意のobject name」にチェックで自動入力される|
|③  | リソースARN |対象バケットのARN/settings/*  |①, ②入力で自動入力される|


ちなみに、JSON形式にすると、以下の通り。

```JSON:
{
	"Version": "2012-10-17",
	"Statement": [
		{
			"Sid": "VisualEditor0",
			"Effect": "Allow",
			"Action": [
				"s3:PutObject",
				"s3:GetObject",
				"s3:ListBucket"
			],
			"Resource": [
				"arn:aws:s3:::my-test-bucket-20231110/settings/*",
				"arn:aws:s3:::my-test-bucket-20231110/settings",
				"arn:aws:s3:::my-test-bucket-20231110"
			]
		}
	]
}
```


(3) ポリシー名に名称を入力し（①）、説明欄に説明を入力（②: 任意）したら（②）、「ポリシーの作成」をクリック(①）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/356958/28d3d4ea-0f68-1f86-f179-b5b5edec5ea0.png)

(4) 続いて、IAMロールを作成します。[IAM](https://us-east-1.console.aws.amazon.com/iam/)にて、「ロール」（①） > 「ロールを作成」をクリック（②）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/356958/94f9e397-d55f-3c39-473a-2fa52d7833fa.png)

(5) 「信頼されたエンティティ」タイプにて、「AWSのサービス」を選択し（①）、「サービスまたはユースケース」の「Lambda」を選択し（②）、「次へ」をクリック（③）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/356958/10a27b90-65fa-ce71-269e-8d65b508c886.png)

(6) 「許可を追加」画面にて、(3) で入力したポリシー名を検索（①）、選択し（②）、「次へ」（③）をクリック
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/356958/0cac252f-de0e-5bf2-e419-b961f7af4798.png)

(7) 「ロール名」を入力し（①）、「ロールを作成」をクリック（②）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/356958/ace9536e-3149-bc4d-8c9e-b6b608755363.png)


### 3. Lambda関数の作成

続いて、Lambda関数を作成します。今回、言語はPython 3.11でいきます。

(1) [Lambda](https://ap-northeast-1.console.aws.amazon.com/lambda/)にアクセスし、「関数の作成」をクリック（①）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/356958/b39c2b74-ad94-540c-ee98-76c52eb26a66.png)

(2) 「関数の作成」画面に遷移したら、以下の通り入力・選択し、「関数の作成」（④）をクリック。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/356958/df46c961-187f-4d8f-b78b-54ff7338f715.png)

|No  |項目名  |入力 or 選択内容  |
|---|---|---|
|①  |関数名  |お好みの関数名を入力  |
|②  |ランタイム  |（今回は）Python3.11  |
|③  |既存のロール  |手順2で作成したIAMロールを選択  |

(3) 画面遷移したら、「コード」タブをクリック
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/356958/a56335dc-d308-e122-f884-f62cd90a452e.png)

(4) 「lambda_function」にソースコードを入力し（①※）、「Deploy」をクリック（②）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/356958/41e3600f-b1ff-ac9a-0c72-3a0499882833.png)

※ソースコード

```py:update_json
import boto3
import json
import logging
from collections import OrderedDict

# ロガーの設定
logger = logging.getLogger()
logger.setLevel(logging.INFO)

# 各種設定
bucket_name = 'my-test-bucket-20231110'
target_prefix = 'settings/'
backup_prefix = f'{target_prefix}backup/'
ver_prev = '1.0.0'  # 更新前のバージョン ☆ Version変更の内容に沿って書き換え
ver_live = '1.1.0'  # 更新後のバージョン ☆ Version変更の内容に沿って書き換え


# S3 クライアントの初期化
s3_client = boto3.client('s3')

def lambda_handler(event, context):
    # 関数内で使用する変数を宣言
    total_file_count = 0  # 総ファイル数
    updated_file_count = 0  # 更新されたファイル数
    error_messages = []  # エラーメッセージを記録するリスト
    not_updated_files = [] # 更新対象でないファイルリスト
    try:
        # バケット内の全ファイルリストを取得: list_objects_v2では最大1,000件しか取得できないためpaginatorで対処
        paginator = s3_client.get_paginator('list_objects_v2')
        pages = paginator.paginate(Bucket=bucket_name, Prefix=target_prefix)

        # バックアップフォルダの存在確認と作成
        response = s3_client.list_objects_v2(Bucket=bucket_name, Prefix=backup_prefix)
        if 'Contents' not in response:
            s3_client.put_object(Bucket=bucket_name, Key=backup_prefix)

        # 各ファイルに対して処理を実行
        for page in pages:
           for item in page.get('Contents', []):
                file_key = item['Key']
                if file_key.startswith(target_prefix) and file_key.endswith('.json') and not file_key.startswith(backup_prefix): 
                    # JSONファイル数をカウント
                    total_file_count += 1
                    try:
                        # ファイルの内容を取得
                        file = s3_client.get_object(Bucket=bucket_name, Key=file_key)
                        file_content = file['Body'].read().decode('utf-8')
                        json_content = json.loads(file_content, object_pairs_hook=OrderedDict)
                    
                        # Versionがver_prevの場合のみ処理
                        if json_content.get('Version') == ver_prev:
                            # バックアップ作成
                            backup_key = backup_prefix + file_key.split('/')[-1]
                            s3_client.copy_object(Bucket=bucket_name, CopySource={'Bucket': bucket_name, 'Key': file_key}, Key=backup_key)
                        
                            # --- ☆ Version変更の内容に沿って書き換え: ここから ☆ --- #
                        
                            # Country要素の後ろにRegion要素を追加
                            country_index = list(json_content.keys()).index('Country') + 1
                            json_content = OrderedDict(list(json_content.items())[:country_index] + [('Region', '')] + list(json_content.items())[country_index:])
                        
                            # --- ☆ Version変更の内容に沿って書き換え: ここまで ☆ --- #                        
                            
                            # Versionを更新
                            json_content['Version'] = ver_live
                        
                            # 更新された内容でファイルを再アップロード: ensure_ascii=FalseでUnicodeエスケープ対策を実施
                            s3_client.put_object(Bucket= bucket_name, Key=file_key, Body=json.dumps(json_content, indent=4, ensure_ascii=False))
                            logger.info(f'Updated and backed up file: {file_key}')
                        
                            # 更新されたJSONファイル数をカウント
                            updated_file_count += 1
                        else:
                            # 更新対象でない場合はファイル名を取得
                            not_updated_files.append(file_key)
                    
                    except Exception as e:
                        error_messages.append(f"Error processing file {file_key}: {str(e)}")
                        
    except Exception as e:
        logger.error(f"An error occurred: {e}")
    
    # 処理終了後のメッセージ: 総ファイル数、更新ファイル数、エラー発生ファイル数、エラーメッセージ
    logger.info('Finished processing S3 files.')
    logger.info('================== results ==================')
    logger.info(f'total file count directly below settings: {total_file_count}')
    logger.info(f'updated file count: {updated_file_count}')
    error_len = len(error_messages)
    logger.info(f'error count: {error_len}')
    logger.info('---------- error details ----------')
    if error_len == 0:
        logger.info('there are no errors')
    for error_message in error_messages:
        logger.error(error_message)
    logger.info('-----------------------------------')
    logger.info('-------- files not updated --------')
    if len(not_updated_files) == 0:
        logger.info('there are no files to update')
    for not_updated_file in not_updated_files:
        logger.error(not_updated_file)
    logger.info('-----------------------------------')
    logger.info('=============================================')
```

:::note info
上記ソースコードの内容は以下の通り
- 設定ファイルのVersionが1.0.0の場合に、1.1.0に更新
- 更新前に、対象ファイル（Versionが1.0.0）のバックアップを取得
- Country要素の後ろに、Region要素を空白で追加
- ファイル更新処理時にエラーが発生しても処理を最後まで継続
- 処理の最後に、
    - settings直下の総ファイル数
    - 更新ファイル数
    - エラー発生数
    - エラー発生ファイル名とその詳細
    - settings直下で更新対象にならなかったファイル名

を表示
:::

(5) テストを作成します。
「テスト」タブを選択し(①）、イベント名に入力（②）、「テスト」をクリック（③）して成功したら、「保存」をクリック（④）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/356958/7547afd6-b534-9af5-fed8-5b4e335238dd.png)

(6) 続いて、バージョンも発行しておきましょう。
「バージョン」タブを選択し、「新しいバージョンを発行」をクリック（②）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/356958/5e459de8-561e-2a24-f09f-b9333ed2ebf8.png)

(7) 「バージョンの説明」に簡単な説明を入力し（①）、「発行」をクリック（②）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/356958/d7b32ccb-ff0c-3a52-3c4c-1cc275c13ebd.png)

(8) バージョンが作成されました。
これによって、この時点のコードやテストの内容が保持されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/356958/5e316cca-4ec1-0632-6ae1-3d887dc11b25.png)

※最初のバージョン発行時に一部内容を誤ってそれを削除したため、2になっております。

### 4. 動作確認

最後に簡単ではありますが、動作確認をしておきます。

(1) S3バケットのsettings配下に以下の3ファイル（①※）をアップロード（②）。
なお、設定ファイル（JSONファイルの）文字コードはUTF-8であるものとします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/356958/828f671e-876b-c35c-8e8a-31e95c08f3ca.png)

※以下、設定ファイルの内容です。

設定ファイルその1: 正常に更新される
```json:jp-001.json
{
	"Version": "1.0.0",
	"Language": "Japanese",
	"Country": "Japan",
	"Type": "Cat"
}
```

設定ファイルその2: Versionが1.0.0でないため更新対象外
```json:ch-001.json
{
	"Version": "1.1.0",
	"Language": "Chinese",
	"Country": "China",
	"Region": "Beijing",
	"Type": "Cat"
}
```

設定ファイルその3: Countryが存在しない（綴り間違い）ため、エラーが発生する
```json:uk-001.json
{
	"Version": "1.0.0",
	"Language": "English",
	"Countory": "UK",
	"Type": "Cat"
}
```

(2) 以下ファイルアップロード後(更新前）のsettings直下とその下のbackup直下の様子です。
▼settings直下
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/356958/2aa6338c-a2c7-34e9-d253-4204a9bf9ad2.png)
▼settings/backup直下
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/356958/5632f4e5-c308-b469-dd05-826708489ff9.png)

(3) Lambdaの対象関数に移動し、「Test」をクリック
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/356958/5eb95098-97c7-9ef5-6aa5-570075a45468.png)

(4) 以下、実行後のログの内容です。

```log:Execution result
Test Event Name
test-s3-20231110

Response
null

Function Logs
[INFO]	2023-11-12T05:34:15.495Z		Found credentials in environment variables.
START RequestId: xxxxx761-60b6-49f0-86c1-cdaf4caxxxxx Version: $LATEST
[INFO]	2023-11-12T05:34:16.283Z	xxxxx761-60b6-49f0-86c1-cdaf4caxxxxx	Updated and backed up file: settings/jp-001.json
[INFO]	2023-11-12T05:34:16.345Z	xxxxx761-60b6-49f0-86c1-cdaf4caxxxxx	Finished processing S3 files.
[INFO]	2023-11-12T05:34:16.345Z	xxxxx761-60b6-49f0-86c1-cdaf4caxxxxx	================== results ==================
[INFO]	2023-11-12T05:34:16.345Z	xxxxx761-60b6-49f0-86c1-cdaf4caxxxxx	total file count directly below settings: 3
[INFO]	2023-11-12T05:34:16.345Z	xxxxx761-60b6-49f0-86c1-cdaf4caxxxxx	updated file count: 1
[INFO]	2023-11-12T05:34:16.345Z	xxxxx761-60b6-49f0-86c1-cdaf4caxxxxx	error count: 1
[INFO]	2023-11-12T05:34:16.345Z	xxxxx761-60b6-49f0-86c1-cdaf4caxxxxx	---------- error details ----------
[ERROR]	2023-11-12T05:34:16.345Z	xxxxx761-60b6-49f0-86c1-cdaf4caxxxxx	Error processing file settings/uk-001.json: 'Country' is not in list
[INFO]	2023-11-12T05:34:16.345Z	xxxxx761-60b6-49f0-86c1-cdaf4caxxxxx	-----------------------------------
[INFO]	2023-11-12T05:34:16.345Z	xxxxx761-60b6-49f0-86c1-cdaf4caxxxxx	-------- files not updated --------
[ERROR]	2023-11-12T05:34:16.345Z	xxxxx761-60b6-49f0-86c1-cdaf4caxxxxx	settings/ch-001.json
[INFO]	2023-11-12T05:34:16.345Z	xxxxx761-60b6-49f0-86c1-cdaf4caxxxxx	-----------------------------------
[INFO]	2023-11-12T05:34:16.345Z	xxxxx761-60b6-49f0-86c1-cdaf4caxxxxx	=============================================
END RequestId: xxxxx761-60b6-49f0-86c1-cdaf4caxxxxx
REPORT RequestId: xxxxx761-60b6-49f0-86c1-cdaf4caxxxxx	Duration: 742.77 ms	Billed Duration: 743 ms	Memory Size: 128 MB	Max Memory Used: 82 MB	Init Duration: 414.04 ms

Request ID
xxxxx761-60b6-49f0-86c1-cdaf4caxxxxx
```

(5) 実行後(更新後）の、settingsフォルダとその直下のbackupフォルダの様子は以下の通り。

▼settings直下
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/356958/db395f11-b0c3-9620-da80-f0a5894238ec.png)

:::note info
ログに記載の内容通り、jp-001.jsonのみ更新されて、ch-001.json、uk-001.jsonは更新されていません。
:::

▼settings/backup直下
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/356958/60f96bc4-a5c3-73a2-1a7d-5aa9c5509a6f.png)

:::note info
バックアップについては、Versionが1.0.0のjp-001.jsonとuk-001.jsonが取得されています。
:::

(6) 更新後のjp-001.jsonの内容は以下の通り。

```JSON:jp-001.json
{
    "Version": "1.1.0",
    "Language": "Japanese",
    "Country": "Japan",
    "Region": "",
    "Type": "Cat"
}
```
:::note info
Country要素の後ろに、Region要素が空文字で追加され、Versionが1.0.0から1.1.0に変更されています。
:::

以上、LambdaでS3上のJSON形式の設定ファイルが更新できました。


## 終わりに

アクセス権回りで少々躓きましたが、無事やりたいことが実現できました。
※最初、S3のバケットポリシーの設定についても書いていたのですが、同一アカウント内ではIAMロールかどちらかが設定できていればよいため、削除しました。

設定ファイル（JSONファイル）のVersion更新毎に、ソースコードのver_prevとver_live、「Version変更の内容に沿って書き換え」の部分を書き換えつつ、lambda自体のバージョンも更新すると、いい感じにバージョン管理できるはず。

## 参考

- [IAM ポリシーの作成 (コンソール) | aws](https://docs.aws.amazon.com/ja_jp/IAM/latest/UserGuide/access_policies_create-console.html)
- [Lambda 実行ロール | aws](https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/lambda-intro-execution-role.html)
- [S3バケットポリシーとIAMポリシーの関係を整理する | BioErrorLog Tech Blog](https://www.bioerrorlog.work/entry/s3-bucket-policy-vs-iam-policy)
- [バケットポリシーの例 | aws](https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/userguide/example-bucket-policies.html)
- [S3 Client | Boto3 1.28.84 documentation](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/s3.html)
- [Lambda 関数を呼び出す | aws](https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/lambda-invocation.html)