AWS CDK - Custom Resource (AwsCustomResource と Provider)

# 内容

CDK で Custom Resource を利用する場合、  
[Provider](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.custom_resources.Provider.html) + [CustomResource](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.CustomResource.html) を使用する方法と、
[AwsCustomResource](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.custom_resources.AwsCustomResource.html)を使用する方法がある。  
それぞれの使用方法を記載する。  
今回は、適当な Lambda Function (sample1Function, sample2Function) をそれぞれ実行する Custom Resource を作成する。  

# AwsCustomResource

## 概要

単一のAPIを呼び出すだけの目的で Custom Resource を利用する場合に使用できる。  
今回は、Custom Resource で [Invoke](https://docs.aws.amazon.com/ja_jp/lambda/latest/api/API_Invoke.html) APIを実行する。  
複数の Custom Resource を作成する場合に多少クセがあり、注意点を後述する。  

## 実装

```typescript
import * as path from 'node:path';

import { Stack, StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as cr from 'aws-cdk-lib/custom-resources';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as lambda from 'aws-cdk-lib/aws-lambda';

export class AwsCustomResourceSampleStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);
    const stackName = this.stackName;

    // 適当なLambda Function 1
    const sample1FunctionName = `${stackName}-sample1Function`;
    const sample1Function = new lambda.Function(this, sample1FunctionName, {
      code: lambda.Code.fromAsset(path.join(__dirname, 'handlers')),
      handler: 'sample1.handler',
      runtime: lambda.Runtime.PYTHON_3_11,
      functionName: sample1FunctionName,
    });

    // 適当なLambda Function 2
    const sample2FunctionName = `${stackName}-sample2Function`;
    const sample2Function = new lambda.Function(this, sample2FunctionName, {
      code: lambda.Code.fromAsset(path.join(__dirname, 'handlers')),
      handler: 'sample2.handler',
      runtime: lambda.Runtime.PYTHON_3_11,
      functionName: sample2FunctionName,
    });

    // Lambda Functionを実行するCustom ResourceにアタッチするRole
    // ※ 全Custom Resourceを実行できるように権限を付与する
    const customResourceFunctionName = `${stackName}-CustomResourceFunction`;
    const customResourceFunctionRole = new iam.Role(this, `${customResourceFunctionName}Role`, {
      assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'),
      inlinePolicies: {
        lambdaPolicy: new iam.PolicyDocument({
          statements: [
            new iam.PolicyStatement({
              actions: ['lambda:InvokeFunction'],
              resources: [sample1Function.functionArn, sample2Function.functionArn],
            }),
          ],
        }),
      },
      roleName: `${customResourceFunctionName}Role`,
    });

    // Custom Resourceで実行する内容 (sample1Functionを実行する)
    const sample1FunctionInvokeContent = {
      action: 'Invoke',
      service: 'Lambda',
      parameters: {
        /* eslint-disable @typescript-eslint/naming-convention */
        FunctionName: sample1Function.functionName,
        InvocationType: 'Event',
        Payload: JSON.stringify({ datetime: Date.now() }),  // 毎回Custom Resourceを実行する工夫
        /* eslint-enable @typescript-eslint/naming-convention */
      },
      physicalResourceId: cr.PhysicalResourceId.of(`${stackName}-sample1FunctionInvokeCustomResource`),
    };

    // sample1Functionを実行するCustom Resource
    new cr.AwsCustomResource(this, `${stackName}-sample1FunctionInvokeCustomResource`, {
      installLatestAwsSdk: true,
      functionName: customResourceFunctionName,
      onCreate: sample1FunctionInvokeContent,
      onUpdate: sample1FunctionInvokeContent,
      role: customResourceFunctionRole,
    });

    // Custom Resourceで実行する内容 (sample2Functionを実行する)
    const sample2FunctionInvokeContent = {
      action: 'Invoke',
      service: 'Lambda',
      parameters: {
        /* eslint-disable @typescript-eslint/naming-convention */
        FunctionName: sample2Function.functionName,
        InvocationType: 'Event',
        /* eslint-enable @typescript-eslint/naming-convention */
      },
      physicalResourceId: cr.PhysicalResourceId.of(Date.now().toString()),  // 毎回Custom Resourceを実行する工夫
    };

    // sample2Functionを実行するCustom Resource
    new cr.AwsCustomResource(this, `${stackName}-sample2FunctionInvokeCustomResource`, {
      installLatestAwsSdk: true,
      functionName: customResourceFunctionName,
      onCreate: sample2FunctionInvokeContent,
      onUpdate: sample2FunctionInvokeContent,
      role: customResourceFunctionRole,
    });
  }
}
```

`cr.AwsCustomResource` の `onCreate`、`onUpdate` で指定する `parameters` は、[Invoke](https://docs.aws.amazon.com/ja_jp/lambda/latest/api/API_Invoke.html)を参照。  

Custom Resource のUpdateイベントは、Custom Resourceに変更がない限り発生しない。  
（Custom Resource から実行するLambda Functionに変更があっても発生しない）  
そのため、Custom Resource を毎回実行したい場合は、日時`Date.now()`等を使って工夫する。  

## 複数の Custom Resource を作成する場合の注意点

```text
These calls are created using a singleton Lambda function.
--- (翻訳) ---
これらの呼び出しは、シングルトン Lambda 関数を使用して作成されます。
```

[AwsCustomResource](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.custom_resources.AwsCustomResource.html) に上記が記載されている。  
これはCDK実装から生成されるCFNテンプレートを見るとわかりやすい。  

```yaml
  # Lambda Functionを実行するCustom ResourceにアタッチするRole
  sampleAwsCustomResourceSampleStackCustomResourceFunctionRole2E76:
    Type: AWS::IAM::Role
    Properties:
      Policies:
        - PolicyDocument:
            Statement:
              - Action: lambda:InvokeFunction
                Effect: Allow
                Resource:
                  - Fn::GetAtt:
                      - sampleAwsCustomResourceSampleStacksample1Function7826
                      - Arn
                  - Fn::GetAtt:
                      - sampleAwsCustomResourceSampleStacksample2Function301A
                      - Arn
            Version: "2012-10-17"
          PolicyName: lambdaPolicy
      RoleName: sample-AwsCustomResourceSampleStack-CustomResourceFunctionRole

  # sample1Functionを実行するCustom Resource
  sampleAwsCustomResourceSampleStacksample1FunctionInvokeCustomResource361C:
    Type: Custom::AWS
    Properties:
      ServiceToken:
        Fn::GetAtt:
          - AWS679f53fac002430cb0da5b7982bd22872D16
          - Arn

  # Custom Resourceとして実行されるLambda Function
  AWS679f53fac002430cb0da5b7982bd22872D16:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: sample-AwsCustomResourceSampleStack-CustomResourceFunction
      Handler: index.handler
      Role:
        Fn::GetAtt:
          - sampleAwsCustomResourceSampleStackCustomResourceFunctionRole2E76
          - Arn
      Runtime: nodejs18.x

  # sample2Functionを実行するCustom Resource
  sampleAwsCustomResourceSampleStacksample2FunctionInvokeCustomResource3FA7:
    Type: Custom::AWS
    Properties:
      ServiceToken:
        Fn::GetAtt:
          - AWS679f53fac002430cb0da5b7982bd22872D16
          - Arn
```

CDK上で`AwsCustomResource`で2つのCustom Resourceを作成しているが、  
Custom Resourceとして実行されるLambda Functionは1つで、  
共通パーツとして2つのCustom Resourceから使用される。  

そして、そのLambda FunctionにアタッチされるRoleも1つなので、  
Roleには全Custom Resourceを実行できるように権限を付与する必要がある。  

# Provider

## 概要

CFN や SAM で実装する Custom Resource に似ている。  
複数のAPIを呼び出したり、ロジックを組み込んだ Custom Resource を利用する場合に使用。  

## 実装

```typescript
import * as path from 'node:path';

import { CustomResource, Stack, StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as cr from 'aws-cdk-lib/custom-resources';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as lambda from 'aws-cdk-lib/aws-lambda';

export class CustomResourceSampleStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);
    const stackName = this.stackName;

    // 適当なLambda Function 1
    const sample1FunctionName = `${stackName}-sample1Function`;
    const sample1Function = new lambda.Function(this, sample1FunctionName, {
      code: lambda.Code.fromAsset(path.join(__dirname, 'handlers')),
      handler: 'sample1.handler',
      runtime: lambda.Runtime.PYTHON_3_11,
      functionName: sample1FunctionName,
    });

    // 適当なLambda Function 2
    const sample2FunctionName = `${stackName}-sample2Function`;
    const sample2Function = new lambda.Function(this, sample2FunctionName, {
      code: lambda.Code.fromAsset(path.join(__dirname, 'handlers')),
      handler: 'sample2.handler',
      runtime: lambda.Runtime.PYTHON_3_11,
      functionName: sample2FunctionName,
    });

    // Lambda Functionを実行するCustom ResourceにアタッチするRole
    const customResourceFunctionRole = new iam.Role(this, `${stackName}-CustomResourceFunctionRole`, {
      assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'),
      inlinePolicies: {
        lambdaPolicy: new iam.PolicyDocument({
          statements: [
            new iam.PolicyStatement({
              actions: ['lambda:InvokeFunction'],
              resources: [sample1Function.functionArn, sample2Function.functionArn],
            }),
          ],
        }),
      },
      roleName: `${stackName}-CustomResourceFunctionRole`,
    });

    // Custom Resourceとして実行するLambda Function
    const invokeLambdaFunction = new lambda.Function(this, `${stackName}-invokeLambdaFunction`, {
      code: lambda.Code.fromAsset(path.join(__dirname, 'handlers')),
      handler: 'invoke_lambda.handler',
      runtime: lambda.Runtime.PYTHON_3_11,
      functionName: `${stackName}-invokeLambdaFunction`,
      role: customResourceFunctionRole,
    });

    // Custom Resourceとして実行するLambda FunctionをProviderに指定する
    const invokeLambdaProvider = new cr.Provider(this, `${stackName}-invokeLambdaProvider`, {
      onEventHandler: invokeLambdaFunction,
    });

    // sample1Functionを実行するCustom Resource
    new CustomResource(this, `${stackName}-invokeSample1CustomResource`, {
      serviceToken: invokeLambdaProvider.serviceToken,
      properties: {
        FUNCTION_NAME: sample1Function.functionName
      }
    });

    // sample2Functionを実行するCustom Resource
    new CustomResource(this, `${stackName}-invokeSample2CustomResource`, {
      serviceToken: invokeLambdaProvider.serviceToken,
      properties: {
        FUNCTION_NAME: sample2Function.functionName
      }
    });
  }
}
```

CustomResource の [serviceToken](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.CustomResource.html#servicetoken) には、
`provider.serviceToken` 以外にも、  
Lambda FunctionのARNや、SNSのトピックARNを直に指定することも可能だが非推奨となっている。  

```python:handlers/invoke_lambda.py
import boto3  # type: ignore


def handler(event, context):
    request_type = event["RequestType"]
    properties = event["ResourceProperties"]
    lambda_client = boto3.client("lambda")

    if request_type == "Create" or request_type == "Update":
        return sync_invoke_lambda(lambda_client, properties["FUNCTION_NAME"])
    if request_type == "Delete":
        return
    raise Exception("Invalid request type: %s" % request_type)


def sync_invoke_lambda(client, function_name):
    client.invoke(
        FunctionName=function_name,
        InvocationType="RequestResponse",
    )
```

CFN、SAM での Custom Resource 実装とは異なり、[cfn-response](https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/cfn-lambda-function-code-cfnresponsemodule.html) を呼び出す必要はない。  
（Custom Resource Lambda Function の成功/失敗に応じてProviderがCFNに応答してくれる）  

## 備考

Provider を使用した場合、CFNテンプレートは以下のようになっている。  
Provider によって Custom Resource として実行する Lambda Function を呼び出す Lambda Function が作成される。  

```yaml
  # Lambda Functionを実行するCustom ResourceにアタッチするRole
  sampleCustomResourceSampleStackCustomResourceFunctionRole5814:
    Type: AWS::IAM::Role
    Properties:
      Policies:
        - PolicyDocument:
            Statement:
              - Action: lambda:InvokeFunction
                Effect: Allow
                Resource:
                  - Fn::GetAtt:
                      - sampleCustomResourceSampleStacksample1FunctionB498
                      - Arn
                  - Fn::GetAtt:
                      - sampleCustomResourceSampleStacksample2Function98EB
                      - Arn
            Version: "2012-10-17"
          PolicyName: lambdaPolicy
      RoleName: sample-CustomResourceSampleStack-CustomResourceFunctionRole

  # Custom Resourceとして実行するLambda Function
  sampleCustomResourceSampleStackinvokeLambdaFunction10F04414:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: sample-CustomResourceSampleStack-invokeLambdaFunction
      Handler: invoke_lambda.handler
      Role:
        Fn::GetAtt:
          - sampleCustomResourceSampleStackCustomResourceFunctionRole5814
          - Arn
      Runtime: python3.11

  # providerのLambda Function
  sampleCustomResourceSampleStackinvokeLambdaProviderframeworkonEvent93E9:
    Type: AWS::Lambda::Function
    Properties:
      Description: AWS CDK resource provider framework - onEvent (sample-CustomResourceSampleStack/sample-CustomResourceSampleStack-invokeLambdaProvider)
      Environment:
        Variables:
          USER_ON_EVENT_FUNCTION_ARN:
            Fn::GetAtt:
              - sampleCustomResourceSampleStackinvokeLambdaFunction10F04414
              - Arn
      Handler: framework.onEvent
      Runtime: nodejs18.x

  # sample1Functionを実行するCustom Resource
  sampleCustomResourceSampleStackinvokeSample1CustomResource:
    Type: AWS::CloudFormation::CustomResource
    Properties:
      ServiceToken:
        Fn::GetAtt:
          - sampleCustomResourceSampleStackinvokeLambdaProviderframeworkonEvent93E9
          - Arn
      FUNCTION_NAME:
        Ref: sampleCustomResourceSampleStacksample1FunctionB498ADB6

  # sample2Functionを実行するCustom Resource
  sampleCustomResourceSampleStackinvokeSample2CustomResource:
    Type: AWS::CloudFormation::CustomResource
    Properties:
      ServiceToken:
        Fn::GetAtt:
          - sampleCustomResourceSampleStackinvokeLambdaProviderframeworkonEvent93E9
          - Arn
      FUNCTION_NAME:
        Ref: sampleCustomResourceSampleStacksample2Function98EB7E85
```

# 参考

- [Provider 概要](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.custom_resources-readme.html#provider-framework)
- [AwsCustomResource 概要](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.custom_resources-readme.html#custom-resources-for-aws-apis)