😨

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

2024/11/15に公開

やりたいこと

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 関数が含まれていたのはこういうことだった。

公式

何はともあれ使ってみる

公式のサンプルをもとにめちゃくちゃシンプルに書いてみる。
前提条件としては

  • TypeScript
  • 既にS3にcdk-deployemnt-test-xxxxバケットを作成済み。
  • cdk アプリケーションに/assets/example.json.zipがある。(example.jsonを圧縮)
{
  "example": "testExample001"
}

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

中身を変えてみる

example.jsonの中身をtestExample002に変えてみる。

{
  "example": "testExample002"
}

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

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

リソースを削除してみる

BucketDeploymentリソース自体を削除するとどうなるだろうか。example.jsonは消えるのか、残るのか。何も考えず、まずやってみる。

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
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を追加してデプロイしてから、

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.jsonexample2.json、さらに/images/001.pngが存在する
└── cdk-deployemnt-test-xxxxxx/
    └── example/
        └── test002/
            ├── example.json
            ├── example2.json
            └── images/
                └── 001.png
{
  "example": "testExample002変更前"
}

スクリーンショット 2024-06-02 18.14.17.png

まずは、今まで通りやってみる。

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

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

どういうわけか、公式を読んでいっていると、ここに記載されていた。

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オプションを指定するだけ。

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.jsonadmin.jsonがあり、さらに/imagesフォルダがあり、そこの大量の画像が保存されていた。
そこでapp.jsonとadmin.jsonの更新をしようとした際に、

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のみ...。悲惨である。
この場合は、

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

スクリーンショット 2024-06-02 19.17.58.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

GitHubで編集を提案

Discussion