[AWS] CDKのaws-s3-deploymentでS3の特定オブジェクトを更新する際の注意点

## やりたいこと

CDKでS3バケットにあるオブジェクトを更新したい！
今回はCDKの`aws-s3-deployment`を使ってS3にあるオブジェクトを更新する方法を説明します。

https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_s3_deployment-readme.html


公式の文頭の説明は以下。
>This library allows populating an S3 bucket with the contents of .zip files from other S3 buckets or from local disk.
The following example defines a publicly accessible S3 bucket with web hosting enabled and populates it from a local directory on disk.

`aws-s3-deployment`はローカルのzipファイルをS3にアップロードする場合に使えるよとのこと。

仕組みは以下。
>When this stack is deployed (either via cdk deploy or via CI/CD), the contents of the local website-dist directory will be archived and uploaded to an intermediary assets bucket (the StagingBucket of the CDK bootstrap stack). If there is more than one source, they will be individually uploaded.
The BucketDeployment construct synthesizes a Lambda-backed custom CloudFormation resource of type Custom::CDKBucketDeployment into the template. The source bucket/key is set to point to the assets bucket.
The custom resource invokes its associated Lambda function, which downloads the .zip archive, extracts it and issues aws s3 sync --delete against the destination bucket (in this case websiteBucket). If there is more than one source, the sources will be downloaded and merged pre-deployment at this step.


内部的に AWS Lambda 関数を作って、Lambda 関数の中から AWS CLI の `aws s3 sync` コマンドを実行して Amazon S3 にファイルをアップロードしているらしい。
CloudFormationを見るとわかるのでここら辺は、実際にやってみて確かめるが、`Custom::CDKBucketDeployment` の中に AWS Lambda 関数が含まれていたのはこういうことだった。

[公式](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_s3_deployment-readme.html)

## 何はともあれ使ってみる

公式のサンプルをもとにめちゃくちゃシンプルに書いてみる。
前提条件としては
- TypeScript
- 既にS3に`cdk-deployemnt-test-xxxx`バケットを作成済み。
- cdk アプリケーションに`/assets/example.json.zip`がある。（example.jsonを圧縮）
```example.json
{
  "example": "testExample001"
}

```
```upload-stack.ts
import { Stack, StackProps } from "aws-cdk-lib";
import { Construct } from "constructs";
import { Bucket } from "aws-cdk-lib/aws-s3";
import * as s3deployment from "aws-cdk-lib/aws-s3-deployment";

export class UploadStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    // 既にS3にあるバケットを取得
    const existing_bucket = Bucket.fromBucketArn(
      this,
      "cdk-deployemnt-test-xxxx",
      "arn:aws:s3:::cdk-deployemnt-test-xxxx"
    );

    new s3deployment.BucketDeployment(this, "BucketDeploymentExample001", {
      // ローカルにあるアップロードしたいソースを指定。
      sources: [s3deployment.Source.asset("./assets/admin.json.zip")],
      // アップロード先のバケットを指定
      destinationBucket: existing_bucket,
      //  どのフォルダにアップロードしたいかを指定(今回は/example/test001/example.jsonを作る)
      destinationKeyPrefix: "example/test001/",
    });
  }
}

```
`cdk deploy UploadStack`を実行する。
デプロイに成功すると、無事、
`バケット/example/test001/example.json`が作成された。
![スクリーンショット 2024-06-02 17.23.51.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3655825/c775db16-1e56-3308-1e3b-2f594d4aa994.png)

### 中身を変えてみる
`example.json`の中身を`testExample002`に変えてみる。
```example.json
{
  "example": "testExample002"
}

```
既存の`example.json.zip`を一回削除し、新たに変更した`example.json`からzipファイルを作成。その後、`cdk deploy`する。

デプロイし、ダウンロードしてきて、中身を確認すると...
確かに`testExample002`になっていることが確認できた。

### リソースを削除してみる
`BucketDeployment`リソース自体を削除するとどうなるだろうか。example.jsonは消えるのか、残るのか。何も考えず、まずやってみる。
``` upload-stack.ts
import { Stack, StackProps } from "aws-cdk-lib";
import { Construct } from "constructs";
import { Bucket } from "aws-cdk-lib/aws-s3";
import * as s3deployment from "aws-cdk-lib/aws-s3-deployment";

export class UploadStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    //一旦全てをコメントアウト
    // const existing_bucket = Bucket.fromBucketArn(
    //   this,
    //   "cdk-deployemnt-test-xxxxxx",
    //   "arn:aws:s3:::cli-deployemnt-test-xxxxxx"
    // );

    // new s3deployment.BucketDeployment(this, "BucketDeploymentExample001", {
    //   sources: [s3deployment.Source.asset("./assets/example.json.zip")],
    //   destinationBucket: existing_bucket,
    //   destinationKeyPrefix: "example/test001/",
    // });
  }
}

```
↑の状態で同様に`cdk deploy`してみる。
すると...
**example.jsonや作成したフォルダは残ったままになる。**
これは公式にも記載があった。
https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_s3_deployment-readme.html#retain-on-delete

>By default, the contents of the destination bucket will not be deleted when the BucketDeployment resource is removed from the stack or when the destination is changed. 

S3バケットの作成と同じで1度デプロイしたリソースを削除してもデフォルトでは残ったままになるらしい。なるほど、、
もう一つ、この理論でいくと、スタック自体を削除しても残るということになるので、マネコンのCloudFormationから今回の`UploadStack`自体を削除してみる。
![スクリーンショット 2024-06-02 17.46.21.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3655825/6bee3476-15d1-297a-7ff5-a19a01eaad11.png)
example.jsonは残ったまま。なるほど、、
さらに、公式には
>You can use the option retainOnDelete: false to disable this behavior, in which case the contents will be deleted.

の記載があり、`retainOnDelete:false`を指定するとスタックの削除時にオブジェクトも削除できると書いてあるので試してみる。一度`retainOnDelete:false`を追加してデプロイしてから、
``` upload-stack.ts
new s3deployment.BucketDeployment(this, "BucketDeploymentExample001", {
       sources: [s3deployment.Source.asset("./assets/example.json.zip")],
       destinationBucket: existing_bucket,
       destinationKeyPrefix: "example/test001/",
       // retainOnDeleteオプションを追加
       retainOnDelete: false,
});
```

先ほど同様にコメントアウトして再度デプロイする。すると、、
消える、、!!（スタック自体を削除しても同じ）

なかなか恐ろしいので、公式には以下の2点に注意することと記載されている。
>Logical ID Changes
Changing the logical ID of the BucketDeployment construct, without changing the destination (for example due to refactoring, or intentional ID change) will result in the deletion of the objects. This is because CloudFormation will first create the new resource, which will have no affect, followed by a deletion of the old resource, which will cause a deletion of the objects, since the destination hasn't changed, and retainOnDelete is false.

>Destination Changes
When the destination bucket or prefix is changed, all files in the previous destination will first be deleted and then uploaded to the new destination location. This could have availability implications on your users.

要は 論理ID（BucketDeploymentExample001）を変更するとそのリソースが1回削除されますよってことっぽい。1回削除されて、新しいリソースが作成されるから、わずかながらダウンタイムは発生しそう。なので、デフォルト（true）のままがとりあえず安全かな。

## 複数オブジェクトがある場合

今度は、既にバケット内にtest002フォルダがあり、そこにある`example2.json`を更新したい場合を試してみる。
前提条件は、
- 既に`バケット/example/test002`フォルダがある
- そこに`example.json`、`example2.json`、さらに`/images/001.png`が存在する
```
└── cdk-deployemnt-test-xxxxxx/
    └── example/
        └── test002/
            ├── example.json
            ├── example2.json
            └── images/
                └── 001.png
```
``` example2.json
{
  "example": "testExample002変更前"
}

```

![スクリーンショット 2024-06-02 18.14.17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3655825/82567f52-4704-39d9-6c57-ab2c2a86907b.png)

まずは、今まで通りやってみる。
```upload-stack.ts
new s3deployment.BucketDeployment(this, "BucketDeploymentExample002", {
      sources: [s3deployment.Source.asset("./assets/example2.json.zip")],
      destinationBucket: existing_bucket,
      destinationKeyPrefix: "example/test002/",
    });
```

デプロイするとどうなるだろうか、、
`example.json`さらには`/images/001.png`が消えた、、😧😧

どういうわけか、公式を読んでいっていると、[ここ](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_s3_deployment-readme.html#prune)に記載されていた。
>By default, files in the destination bucket that don't exist in the source will be deleted when the BucketDeployment resource is created or updated. You can use the option prune: false to disable this behavior, in which case the files will not be deleted.

デフォルトでは、BucketDeployment リソースが作成または更新されると、ソースに存在しないデスティネーションバケット内のファイルは削除される仕様らしい。
つまり、今回の場合、`destinationKeyPrefix: "example/test002/"`に対しての変更を行なったため、デフォルトでは**このインスタンスの`destinationKeyPrefix`で指定していないオブジェクトは全て削除されるということ**。（001.pngも消えたので、それ以下のオブジェクトも全て削除されるっぽい）

これは恐ろしい、、、（ちなみにこれがわかっていなくて筆者は本番環境でS3のデータをほとんど飛ばすというやらかしをしました...）

では、どうすればいいか。答えは簡単、`prune: false`オプションを指定するだけ。
```upload-stack.ts
new s3deployment.BucketDeployment(this, "BucketDeploymentExample002", {
      sources: [s3deployment.Source.asset("./assets/example2.json.zip")],
      destinationBucket: existing_bucket,
      destinationKeyPrefix: "example/test002/",
      // 追加
      prune: false,
    });
```
これだけ。example.jsonと001.pngを再度アップロードして↑で`cdk deploy`を実行。
すると...
残っている、、そしてexample2.jsonの中身もちゃんと更新されている！
既存の一部オブジェクトに対して何かしら更新したいときはこれほぼ必須じゃないかなという感じ。

ちなみに、私が過去やらかした案件では今回で言うと、
`/example`フォルダに`app.json`と`admin.json`があり、さらに`/images`フォルダがあり、そこの大量の画像が保存されていた。
そこでapp.jsonとadmin.jsonの更新をしようとした際に、
```typescript
new s3deployment.BucketDeployment(this, "BucketDeploymentExample001", {
      sources: [s3deployment.Source.asset("./assets/admin.json.zip")],
      destinationBucket: existing_bucket,
      destinationKeyPrefix: "example/",
});

new s3deployment.BucketDeployment(this, "BucketDeploymentExample002", {
      sources: [s3deployment.Source.asset("./assets/app.json.zip")],
      destinationBucket: existing_bucket,
      destinationKeyPrefix: "example/",
});
```
のように書いてデプロイを実行した。ここまで読んだ方はもうわかったと思うが、↑の場合、まず`BucketDeploymentExample001`でadmin.jsonを更新しているが、それ以外のオブジェクトを全部削除...。さらにはその後に`BucketDeploymentExample002`によってadmin.jsonも削除されバケットに残ったのは`app.json`のみ...。悲惨である。
この場合は、
```typescript
new s3deployment.BucketDeployment(this, "BucketDeploymentExample001", {
      sources: [
        s3deployment.Source.asset("./assets/app.json.zip"), 
        s3deployment.Source.asset("./assets/admin.json.zip")
      ],
      destinationBucket: existing_bucket,
      destinationKeyPrefix: "example/",
      prune: false,
});
```
と書けば`prune: false`によって既存のオブジェクトには影響を与えず、app.jsonとadmin.jsonを同時に更新することができるだろう。（sourcesが配列になっているのに何故か1つしか指定できないと錯覚していた...）


## おまけ
このモジュールでS3にアップロードされる仕組みについて、ちょっとだけ深掘り。
aws_s3_deploymentモジュールは内部的にLambda関数を作って、Lambda関数の中から CLIの`aws s3 sync`コマンドを実行してS3 にファイルをアップロードしている。Lambda関数のコード`index.py`は以下にある。

https://github.com/aws/aws-cdk/blob/main/packages/%40aws-cdk/custom-resource-handlers/lib/aws-s3-deployment/bucket-deployment-handler/index.py

CloudFormation 見るとカスタムリソースの中にLambda関数が含まれていて，実際に以下のような Lambda関数が作られていた。

![スクリーンショット 2024-06-02 19.16.37.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3655825/9279c088-e174-1482-fac0-15da9e983720.png)

![スクリーンショット 2024-06-02 19.17.58.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3655825/824118ff-14f5-b156-caf3-740378ca10ad.png)


ここで`aws s3 sync`をしていた。



## 最後に
今回は、aws-s3-deploymentモジュールについて説明しました。
結局マネコンでやればいいじゃんと思う方もいるかもしれませんが、CIでやりたい場合やコードベースでレビュー文化を取り入れてやりたい場合もあると思います。そんな時の参考になれば幸いです。
他にもオプションや、アップロード方法が色々あるので気になる方は以下を参考にしてください。

https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_s3_deployment-readme.html#supported-sources