【AWS CDK (Python)】 カスタムリソースの使い方

## はじめに
本記事は、私自身の備忘録を兼ねてAWS CDKをこれから始める方の一助になればと思い、AWS CDKの使い方等をまとめたものです。
今回は、カスタムリソースの作成・実行方法や注意点等を確認しています。
なお、本記事は私自身の経験を基に記載していますが、間違いがあったらすみません。

## カスタムリソースとは
カスタムリソースは、通常のCloudFormationで定義されているリソースタイプでは対応できないような処理を、独自にロジックを記述し、デプロイできるリソースタイプです。
例えば、CloudFormationで作成したS3バケットにオブジェクトを格納した後にスタック削除するとエラーになります。これは、S3バケットの削除は、バケットを空にする必要があるためなのですが、CloudFormationにオブジェクトを削除するリソースタイプは用意されていません。このような場合に、S3バケットを削除する前にカスタムリソースを使用してオブジェクトを削除するといった独自の処理ロジックを作成することができます。
このようにカスタムリソースを使用すると通常ではできない処理を独自に実装できます。便利な反面、独自の処理になるため、可読性等が落ちることになりますので利用の際は注意が必要です。

## 環境
本記事は以下の環境を使用して記載しています。
* AWS Cloud9
* AWS CDK：2.80.0
* Python： 3.10.11
* Node.js： 16.20.0

また、以下の記事に基づいてAWS CDKの環境を作成しています。
* [【AWS CDK/Python】 AWS CDKの始め方](https://qiita.com/takataka987/items/4448dbf06d8c70173305)

## カスタムリソースの使い方
では、本題のカスタムリソースの使い方を確認します。今回は、東京リージョンで実行するCDKスタックからバージニア北部リージョンのパラメータストアに格納されているパラメータを読み込むカスタムリソースを作成してみます。
カスタムリソースのロジックは、Lambda Functionとして作成しますので、今回の流れは以下のようになります。
* [前準備](#前準備)
* [Lambda Functionの作成](#lambda-functionの作成)
* [カスタムリソースの作成](#カスタムリソースの作成)
* [デプロイ、確認](#デプロイ確認)

### 前準備
前準備として、カスタムリソースから読み込みに行くバージニア北部リージョンのパラメータを用意しておきます。
`/test/parameter1`という名前で、パラメータ値が`CUSTOM RESOURCE !`というパラメータを用意しました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3445559/fb072379-7e19-e88d-5fe3-412c29f0ea2f.png)

### Lambda Functionの作成
次にカスタムリソースの実体になる、Lambda Functionを作成します。
ということで、Lambdaでバージニア北部リージョンのパラメータストアのパラメータを読み込みに行くので、Lambdaのソースコードは以下のようにしました。
読み込みに行くリージョン、パラメータ名はLambdaのeventにパラメータとして渡すようにしますので、ここでは、`event['ResourceProperties']`からロードするようにしています。（渡し方は後述します。）
作成する際の注意点がいくつかあります。
1点目は、`cfnresponse.send`です。このLambda FunctionはCloudFormationから実行されることになりますが、実行後、CloudFormationのカスタムリソース用のエンドポイントに応答を返さないとCloudFormationがLambda Functionの終了や異常の有無を判断できないため、待ち続けることになります。ですので、`cfnresponse.send`を使用して必ずCloudFormationへ応答を返しましょう。`cfnresponse.FAILED`を返した場合か、CloudFormationへ応答が返らずにタイムアウト(1h)した場合は、スタックがロールバックされます。
注意点の2点目は、`event['RequestType']`です。カスタムリソースに対する作成、更新、削除を判定することができます。今回の例だと、作成と更新の際にパラメータストアのパラメータを読み込みに行く処理をしたいので、作成、更新のみにロジックを実装し、削除の際はCloudFormationへ応答を返すのみとしています。このように作成、更新、削除に合わせて必要なロジックを実装しましょう。

```python:get_parameters.py
import boto3
import cfnresponse
from botocore.exceptions import ClientError

def lambda_handler(event, context):

    params = dict([(k, v) for k, v in event['ResourceProperties'].items() if k != 'ServiceToken'])

    # スタック、リソース削除の時は処理なし.
    if event['RequestType'] == 'Delete':
        cfnresponse.send(event, context, cfnresponse.SUCCESS, {})

    # リソース作成、更新の時は指定リージョンの指定パラメータを読み込む.
    if event['RequestType'] == 'Create' or event['RequestType'] == 'Update':
        try:
            ssm = boto3.client('ssm', region_name=params["target_region"])
            response = ssm.get_parameters_by_path(
                Path=params["param_path"],
                Recursive=True
            )
        except ClientError as e:
            cfnresponse.send(event, context, cfnresponse.FAILED, {})
            print(e)
            return
        else:
            response_data = {}
            for parameter in response["Parameters"]:
                response_data[parameter["Name"]] = parameter["Value"]
            response_data["param_path"] = params["param_path"]
            print(response_data)
            cfnresponse.send(event, context, cfnresponse.SUCCESS, response_data)
```

では、このソースコードを使用してLambda Functionを作成するCDKスタックを作成します。
Lambda用のIAMロールの作成、Cloud9のローカルにあるソースコードの読み込み、Lambda Functionの作成を行います。
ポリシーは、AWSLambdaBasicExecutionRoleとパラメータストアのパラメータを取得するので、AmazonSSMReadOnlyAccessを使用しています。
```python:cdk_app/cdk_app_stack.py
from aws_cdk import (
    Stack,
    aws_iam as iam,
    aws_lambda as lambda_,
)
from constructs import Construct
import random, string

class CdkAppStack(Stack):

    def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None:
        super().__init__(scope, construct_id, **kwargs)

        # Lambda用のIAMロールの作成.
        cfn_role = iam.CfnRole(
                self,
                "lambda_role",
                description="test custom resource.",
                assume_role_policy_document={
                    "Version": "2012-10-17",
                    "Statement": [
                        {
                            "Action": "sts:AssumeRole",
                            "Effect": "Allow",
                            "Principal": {
                                "Service": "lambda.amazonaws.com"
                            }
                        }
                    ]
                },
                managed_policy_arns=[
                    "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole",
                    "arn:aws:iam::aws:policy/AmazonSSMReadOnlyAccess"
                ],
                role_name="lambda_role"
            )

        # Lambdaコードの読み込み.
        with open("./cdk_app/get_parameters.py") as f:
            code_data = f.read()

        # Lambda Functionの作成.
        cfn_function = lambda_.CfnFunction(
            self,
            "get_parameters",
            code=lambda_.CfnFunction.CodeProperty(
                zip_file=code_data
            ),
            role=cfn_role.attr_arn,
            architectures=["x86_64"],
            description="test custom resource.",
            function_name="get_parameters",
            handler="index.lambda_handler",
            runtime="python3.10",
            timeout=30
        )
```

ここまでで、Lambda Functionの作成が出来ました。

### カスタムリソースの作成
引き続き、CDKスタックに作成したLambda Functionを元にしたカスタムリソースを作成します。
カスタムリソースは`aws_cdk.CfnCustomResource`を使用します。
Lambdaに任意のパラメータを渡す場合は、`CfnCustomResourceクラスオブジェクト.add_override("Properties.<パラメータ名>", "<パラメータ値>")`のようにすると、`event['ResourceProperties']['<パラメータ名>']`に渡すことができます。今回は、パラメータを取得しに行くリージョンとパラメータストアのパラメータ名を渡すようにします。
また、カスタムリソースの実行確認のため、バージニア北部リージョンから読み込んだパラメータ値を東京リージョンのパラメータストアに書き込む処理を追加しました。
```python:cdk_app/cdk_app_stack.py(追加)
        # カスタムリソースの作成.
        # us-east-1のパラメータストアからパラメータ取得.
        cfn_custom_resource = CfnCustomResource(
            self,
            "custom_resource",
            service_token=cfn_function.attr_arn
        )
        cfn_custom_resource.add_override("Properties.target_region", "us-east-1")
        cfn_custom_resource.add_override("Properties.param_path", "/test/")

        # 確認用：ap-northeast-1のパラメータストアへの格納.
        cfn_parameter = ssm.CfnParameter(
            self,
            "put_parameter",
            type="String",
            value=cfn_custom_resource.get_att("/test/parameter1").to_string(),
            description="test custom resource.",
            name="/test/parameter2",
        )
```

### デプロイ、確認
では、デプロイして確認してみます。
```console
(.venv) user_name:~/environment/cdk-app (master) $ cdk deploy

✨  Synthesis time: 18.4s

(中略)
This deployment will make potentially sensitive changes according to your current security approval level (--require-approval broadening).
Please confirm you intend to make the following modifications:

IAM Statement Changes
┌───┬────────────────────┬────────┬────────────────┬──────────────────────────────┬───────────┐
│   │ Resource           │ Effect │ Action         │ Principal                    │ Condition │
├───┼────────────────────┼────────┼────────────────┼──────────────────────────────┼───────────┤
│ + │ ${lambda_role.Arn} │ Allow  │ sts:AssumeRole │ Service:lambda.amazonaws.com │           │
└───┴────────────────────┴────────┴────────────────┴──────────────────────────────┴───────────┘
IAM Policy Changes
┌───┬────────────────┬──────────────────────────────────────────────────────────────────┐
│   │ Resource       │ Managed Policy ARN                                               │
├───┼────────────────┼──────────────────────────────────────────────────────────────────┤
│ + │ ${lambda_role} │ arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole │
│ + │ ${lambda_role} │ arn:aws:iam::aws:policy/AmazonSSMReadOnlyAccess                  │
└───┴────────────────┴──────────────────────────────────────────────────────────────────┘
(NOTE: There may be security-related changes not in this list. See https://github.com/aws/aws-cdk/issues/1299)

Do you wish to deploy these changes (y/n)? y
CdkAppStack: deploying... [1/1]
CdkAppStack: creating CloudFormation changeset...

 ✅  CdkAppStack

✨  Deployment time: 56.85s

Stack ARN:
(中略)

✨  Total time: 75.24s
```

パラメータストアを確認すると、意図通りにバージニア北部リージョンのパラメータ値と同じものが格納されていることが確認できました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3445559/7642daa5-9ad0-d41c-b9d9-9004aa61a457.png)

ですが、実はこのカスタムリソースは不完全です。
今回、意図としては、バージニア北部リージョンのパラメータ値を変更し、再度デプロイした場合は、東京リージョンのパラメータ値も更新したいです。
試しに、バージニア北部リージョンのパラメータ値を変更し、再度デプロイしてみます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3445559/8caaa11b-8af8-c4b1-b010-8ec8eb5f65b4.png)

```console
(.venv) user_name:~/environment/cdk-app (master) $ cdk deploy

✨  Synthesis time: 18.17s

(中略)
CdkAppStack: deploying... [1/1]

 ✅  CdkAppStack (no changes)

✨  Deployment time: 0.28s

Stack ARN:
(中略)

✨  Total time: 18.45s
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3445559/7642daa5-9ad0-d41c-b9d9-9004aa61a457.png)

結果は、上図のとおり、東京リージョンのパラメータ値は変更されてませんでした。
これは、なぜかというと、バージニア北部リージョンのパラメータ値に変更があっても、CloudFormation Templateに何ら変更がないため、カスタムリソースのデプロイが実行されないためです。`cdk deploy`の実行結果にも`(no changes)`と出てますね。
これを回避するために、カスタムリソースのパラメータを1つ追加します。
```python:cdk_app/cdk_app_stack.py(カスタムリソースのパラメータ追加)
        cfn_custom_resource.add_override("Properties.random_str",
            ''.join(random.choices(string.ascii_letters + string.digits, k=8)))
```

新しく追加したパラメータには、ランダムな8桁の英数字を設定するようにしてます。（桁数にあまり意味はないです。）こうすることで、スタックを実行する度に異なるランダムな英数字がパラメータに設定されるので、スタックを実行する度にCloudFormation Templateが変わることになり、毎回カスタムリソースがデプロイされることになります。
再度、デプロイしてみます。
```console
(.venv) user_name:~/environment/cdk-app (master) $ cdk diff
Stack CdkAppStack
Resources
[~] AWS::CloudFormation::CustomResource custom_resource customresource 
 └─ [+] random_str
     └─ 74suXZDG

(.venv) user_name:~/environment/cdk-app (master) $ cdk deploy

✨  Synthesis time: 17.52s

(中略)
CdkAppStack: deploying... [1/1]
CdkAppStack: creating CloudFormation changeset...

 ✅  CdkAppStack

✨  Deployment time: 27.1s

Stack ARN:
(中略)

✨  Total time: 44.62s
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3445559/34c9eca7-f025-20a5-2a16-ecd183d1c9b9.png)

今回は無事、パラメータ値が更新されました。`cdk deploy`の実行結果でも変更として処理されてることが分かりますね。
コードは、最終的に以下のようになりました。
```python:cdk_app/cdk_app_stack.py
from aws_cdk import (
    Stack,
    aws_iam as iam,
    aws_lambda as lambda_,
    CfnCustomResource,
    aws_ssm as ssm
)
from constructs import Construct
import random, string

class CdkAppStack(Stack):

    def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None:
        super().__init__(scope, construct_id, **kwargs)

        # Lambda用のIAMロールの作成.
        cfn_role = iam.CfnRole(
                self,
                "lambda_role",
                description="test custom resource.",
                assume_role_policy_document={
                    "Version": "2012-10-17",
                    "Statement": [
                        {
                            "Action": "sts:AssumeRole",
                            "Effect": "Allow",
                            "Principal": {
                                "Service": "lambda.amazonaws.com"
                            }
                        }
                    ]
                },
                managed_policy_arns=[
                    "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole",
                    "arn:aws:iam::aws:policy/AmazonSSMReadOnlyAccess"
                ],
                role_name="lambda_role"
            )

        # Lambdaコードの読み込み.
        with open("./cdk_app/get_parameters.py") as f:
            code_data = f.read()

        # Lambda Functionの作成.
        cfn_function = lambda_.CfnFunction(
            self,
            "get_parameters",
            code=lambda_.CfnFunction.CodeProperty(
                zip_file=code_data
            ),
            role=cfn_role.attr_arn,
            architectures=["x86_64"],
            description="test custom resource.",
            function_name="get_parameters",
            handler="index.lambda_handler",
            runtime="python3.10",
            timeout=30
        )

        # カスタムリソースの作成.
        # us-east-1のパラメータストアからパラメータ取得.
        cfn_custom_resource = CfnCustomResource(
            self,
            "custom_resource",
            service_token=cfn_function.attr_arn
        )
        cfn_custom_resource.add_override("Properties.target_region", "us-east-1")
        cfn_custom_resource.add_override("Properties.param_path", "/test/")
        cfn_custom_resource.add_override("Properties.random_str",
            ''.join(random.choices(string.ascii_letters + string.digits, k=8)))

        # 確認用：ap-northeast-1のパラメータストアへの格納.
        cfn_parameter = ssm.CfnParameter(
            self,
            "put_parameter",
            type="String",
            value=cfn_custom_resource.get_att("/test/parameter1").to_string(),
            description="test custom resource.",
            name="/test/parameter2",
        )
```

## まとめ
カスタムリソースの作成と実行を確認しました。前述のとおり可読性が落ちる可能性があるので、極力使用しないことが望ましいとは思いますが、どうしてもやらなければならない状況は発生し得るので、対応策の1つとして検討の価値はあると思いました。
最後まで読んでいただいてありがとうございます。
少しでも参考になれば幸いです。
#### 参考文献
* [AWS Cloud Development Kit (AWS CDK) v2デベロッパーガイド](https://docs.aws.amazon.com/ja_jp/cdk/v2/guide/home.html)
* [AWS CDK Python Reference](https://docs.aws.amazon.com/cdk/api/v2/python/index.html)
* [cfn-response module](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/cfn-lambda-function-code-cfnresponsemodule.html)