AWS CDKで使える便利なAwsCustomResourceの紹介

この記事は[Japan AWS Jr. Champions Advent Calendar 2023](https://qiita.com/advent-calendar/2023/jr-champions)の20日目の記事です。
AWS CDKの便利なconstructである`AwsCustomResource`について色々と紹介していきます。

## `AwsCustomResource`とは？
`AwsCustomResource`とはAWSのAPIを実行するCloudFormationのカスタムリソースを作成してくれるconstructです。
CloudFormationではカスタムリソースでAPIを実行しようとするとLambda関数を構築する必要がありますが、このconstructを利用すると裏側で指定したAPIを実行するLambda関数を自動作成してくれます。
https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.custom_resources.AwsCustomResource.html

主な用途としてはLambda backedなカスタムリソースと全く同様で、CloudFormationで対応していないリソースの作成・更新・削除になります。
他にも同様にAPIを実行した返り値をCloudFormationテンプレート上で利用するなど様々な場面で利用出来ます。

## どのように利用するか
### 前提
利用するCDKのバージョンは以下です。

```shell-session
$ cdk --version
2.115.0 (build 58027ee)
```

### 構築
今回は分かりやすくAWSのAPIの中でも利用頻度の高そうなS3の`putObject`と`deleteObject`をカスタムリソースから実行する例を想定します。
コードの全体は以下です。

```ts
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { Bucket } from 'aws-cdk-lib/aws-s3';
import { AwsCustomResource, AwsCustomResourcePolicy, PhysicalResourceId } from 'aws-cdk-lib/custom-resources';

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

    const bucket = new Bucket(this, 'MyBucket', {});

    new AwsCustomResource(this, 'MyCustomResource', {
      onCreate: {
        service: 'S3',
        action: 'putObject',
        parameters: {
          Bucket: bucket.bucketName,
          Key: 'my-file.txt',
          Body: 'Hello, world!',
        },
        physicalResourceId: PhysicalResourceId.of('my-file'),
      },
      onDelete: {
        service: 'S3',
        action: 'deleteObject',
        parameters: {
          Bucket: bucket.bucketName,
          Key: 'my-file.txt',
        },
      },
      policy: AwsCustomResourcePolicy.fromSdkCalls({
        resources: [`${bucket.bucketArn}/*`],
      }),
    });
  }
}
```

現在LambdaはデフォルトでNode.js v18のランタイムかつAWS SDK for JavaScript v3を利用して実行されています。

また`onCreate`や`onDelete`でリソースの作成・削除のタイミングで実行するAPIとパラメータの指定を行えます。
ここでは作成時に`putObject`、削除時に`deleteObject`をそれぞれ実行するように設定を行っています。

面白いのはAPIを実行するLambda関数の権限設定です。
[`policy`](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.custom_resources.AwsCustomResource.html#policy)にはLambdaの実行ロールに指定するIAM Policyの設定をしますが、今回は`policy`の設定に[`AwsCustomResourcePolicy.fromSdkCalls`](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.custom_resources.AwsCustomResourcePolicy.html#static-fromwbrsdkwbrcallsoptions)メソッドを利用しました。
このメソッドは`service`と`action`から必要なPolicyを自動で設定します。
このケースだと`pubObject` APIの実行に必要なのは`s3:PutObject`という対応したactionになるので自動で設定が可能です。
ただ、API名と必要なPolicyのactionが対応していないケース（`listObjectV2`など）もあるのでその場合は、[`fromStatements`](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.custom_resources.AwsCustomResourcePolicy.html#static-fromwbrstatementsstatements)メソッドで必要なStatementを渡してあげることが出来ます。

### デプロイをして動かしてみる
実際にデプロイをしたのちに、Bucket内のオブジェクトを取得してみます。

```bash
$ cdk deploy --all

$ aws s3api list-objects-v2 --bucket {bucketName}

{
    "Contents": [
        {
            "Key": "my-file.txt",
            "LastModified": "2023-12-16T11:10:22+00:00",
            "ETag": "\"6cd3556deb0da54bca060b4c39479839\"",
            "Size": 13,
            "StorageClass": "STANDARD"
        }
    ]
}
```

`my-file.txt`が取得出来ているのでputObjectが正しく実行出来ていることが分かります。

次にStackの削除を行い、オブジェクトの削除が出来ているか確認します。

```bash
$ cdk destroy --all

$ aws s3api list-objects-v2 --bucket {bucketName}
```

CLIでオブジェクトのリストを取得しようとしても何も出力されないので無事削除されたことが確認出来ました。

このように`AwsCustomResource`を利用すると自分でLambda関数を記述することなくAWS APIの実行が可能です！

## その他の注意事項や利用方法
### onCreateとonUpdate
`onCreate`を省略し、かつ`onUpdate`を指定した場合、`onCreate`には`onUpdate`で指定したプロパティが設定されます。
UpdateするにはCreateが必要なので自然な挙動ではあるのですが、少し分かりづらいので注意が必要です。

### serviceとactionの指定方法
`onCreate`などに指定する`service`と`action`はいくつかの指定方法が可能です。
こちらのドキュメントによくまとまっているのでぜひご覧ください。
https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.custom_resources.AwsSdkCall.html#service
https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.custom_resources.AwsSdkCall.html#action

LambdaはデフォルトでSDK v3を利用しますが、ドキュメントに記載のv2の方法でも指定が可能です。
内部でservice名とaction名の正規化を行なっているためです。

### SingletonFunction
`AwsCustomResource`は内部的にLambdaの作成時に[`SingletonFunction`](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_lambda.SingletonFunction.html)を利用しるので、Stack内で複数の`AwsCustomResource`を作成してもLambda関数は1つのみ作成されます。

後述する`role`設定などはこの単一のLambdaにアタッチされる形です。

### 権限設定
[`role`](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.custom_resources.AwsCustomResource.html#role)プロパティにIAM Roleを指定することでLambdaの実行ロールを設定することが可能です。
`role`を指定した場合、前述した`AwsCustomResourcePolicy.fromSdkCalls`などで指定したポリシーはこちらのRoleに追加されます。

また`AwsCustomResource`は[`iam.IGrantable`](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_iam.IGrantable.html) interfaceを実装しているため、`grantXXX`メソッドを利用してLambdaの実行ロールに追加ポリシーの追加が出来ます。
前述した`listObjectV2`などの利用時で`AwsCustomResourcePolicy.fromSdkCalls`が使えないケースではこのような権限設定がよりCDKらしい書き方かもしれません。
```ts
export class TestCustomResourceStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const bucket = new Bucket(this, 'MyBucket', {});

    const customResource = new AwsCustomResource(this, 'MyCustomResource', {
      onCreate: {
        service: 'S3',
        action: 'listObjectsV2',
        parameters: {
          Bucket: bucket.bucketName,
        },
        physicalResourceId: PhysicalResourceId.of('my-file'),
      },
      policy: AwsCustomResourcePolicy.fromSdkCalls({
        resources: [`${bucket.bucketArn}/*`],
      }),
    });
    bucket.grantRead(customResource);
  }
}
```

### カスタムリソースから返却された値の利用
カスタムリソースが返却された値をCDK内で利用することが出来ます。

例えば既存パラメータストアから値を取得したいとします。
`GetParameter` APIは以下のような構造でレスポンスを返します。
なので`Parameter.Value`と辿っていけば目的の値が取得できそうです。

```ts
{
  Parameter: {
    Name: "STRING_VALUE",
    Type: "String" || "StringList" || "SecureString",
    Value: "STRING_VALUE",
    Version: Number("long"),
    Selector: "STRING_VALUE",
    SourceResult: "STRING_VALUE",
    LastModifiedDate: new Date("TIMESTAMP"),
    ARN: "STRING_VALUE",
    DataType: "STRING_VALUE",
  },
};
```

https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/client/ssm/command/GetParameterCommand/

このCDKコードでは[`getResponseField`](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.custom_resources.AwsCustomResource.html#getwbrresponsewbrfielddatapath)メソッドを利用して、パラメーターストアから取得した値を参照しています。
引数には欲しいデータへのパスを渡します（今回は`Parameter.Value`）。

```ts
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { AwsCustomResource, AwsCustomResourcePolicy, PhysicalResourceId } from 'aws-cdk-lib/custom-resources';

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

    // https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.custom_resources-readme.html#custom-resource-examples
    const getParameter = new AwsCustomResource(this, 'GetParameter', {
      onUpdate: {
        service: 'SSM',
        action: 'GetParameter',
        parameters: {
          Name: '/test/my-param',
        },
        physicalResourceId: PhysicalResourceId.of(Date.now().toString()),
      },
      policy: AwsCustomResourcePolicy.fromSdkCalls({
        resources: AwsCustomResourcePolicy.ANY_RESOURCE,
      }),
    });

    const someValue = getParameter.getResponseField('Parameter.Value'), // 値の取得
  }
}
```

## 終わりに
この記事では簡単にですが`AwsCustomResource`の紹介をさせていただきました。
まだまだ紹介しきれていない機能がたくさんあるのでぜひドキュメントをご覧ください！
https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.custom_resources-readme.html#custom-resources-for-aws-apis

`AwsCustomResource`を活用してよりよいCustomResourceライフを！