NRIネットコム Blog

NRIネットコム社員が様々な視点で、日々の気づきやナレッジを発信するメディアです

AWS LambdaカスタムリソースでAWS Cloudformationスタックを別リージョンにデプロイする

小西秀和です。
今回はAWS Cloudformationテンプレートから呼び出すことで、別リージョンにAWS CloudformationスタックをデプロイするAWS Lambdaカスタムリソースについて書きたいと思います。
この記事を書こうと思った理由はAmazon CloudFrontのAWS Certificate Managerの証明書やLambda@Edgeがバージニアリージョン(us-east-1)でのみ使用できるという制限があるためです。
例えば、東京リージョン(ap-northeast-1)でAmazon S3とAmazon CloudFrontの静的ホスティング環境を作成するスタックをデプロイした場合、AWS Certificate Manager証明書とLambda@Edgeを追加するにはバージニアリージョン(us-east-1)にリソースを作成する必要があります。
そのため、このようなケースの場合はAWS Cloudformationテンプレートでこれらのリソースをデプロイする選択肢としては次のような方法が考えられます。

  1. すべてのリソースをus-east-1のAWS Cloudformationスタックで作成する
  2. ap-northeast-1でデプロイするAWS CloudformationスタックからLambdaカスタムリソースを呼び出して、必要なリソースのみus-east-1に作成する
  3. AWS Cloudformation StackSetsで必要なリソースのみus-east-1に作成する
  4. 該当部分の自動化をせずにAWSマネジメントコンソールなどで手動作成する

1.であればus-east-1リージョンで完結しますが、他のリージョンでリソースを作成したい場合には選択できません。
2.と3.は積極的に自動化をするアプローチですが、us-east-1で作成したリソースのARNなどをap-northeast-1でデプロイするAWS Cloudformationスタックに渡して、Amazon CloudFrontとAWS Certificate Manager証明書やLambda@Edgeを関連付ける必要があります。
4.同様の環境を何度も作成する必要がない、パラメータ入力以降のプロセスを完全に自動化する必要がないといった場合には開発時間を節約するための現実的な方法です。

今回はこれらのうち2.を使用し、元のAWS CloudformationスタックでLambdaカスタムリソースを呼び出し、us-east-1でAWS Cloudformationスタックによるリソースを作成して返り値を元のAWS Cloudformationスタックで受け取って処理を続ける方法を試します。

※本記事および当執筆者のその他の記事で掲載されているソースコードは自主研究活動の一貫として作成したものであり、動作を保証するものではありません。使用する場合は自己責任でお願い致します。また、予告なく修正することもありますのでご了承ください。

AWS Cloudformationのカスタムリソースとは

AWS Cloudformationでオプションとして使用できるカスタムリソースとは、AWS CloudFormationのリソースタイプとして使用できないリソースをAWS Lambda関数で実行したり、Amazon SNSトピックでトリガーしたりして使用するためのものです。

例えば、外部サービスやオンプレミスシステムを使用する場合などが挙げられますが、今回のようにAWSサービス内部でもリージョンをまたぐ処理やAWS内で構築したマイクロサービスを呼び出すなど使い方は様々です。

ただ、AWS Lambdaでカスタムリソースを実装する場合は実行時間を最大15分以内収める必要があるなど、AWS Lambdaの仕様による制限が発生することに注意が必要となります。

また、AWS Lambdaでカスタムリソースを実装する場合は実行元のAWS Cloudformationスタックに特有の形式でレスポンスを返却する必要があります。
このレスポンスを返却する機能は用意されているCloudformation専用メソッドを使用するか、独自にレスポンスメソッドを作成する必要があります。
詳細については後述します。

別リージョンにAWS CloudformationスタックをデプロイするAWS Lambdaカスタムリソース

まず、今回のAWS Lambdaカスタムリソースの使用パターンを構成図で示します。

Lambdaカスタムリソースによる別リージョンへのCloudformationスタックデプロイと関連付けの例
Lambdaカスタムリソースによる別リージョンへのCloudformationスタックデプロイと関連付けの例

このように主に呼出元のAWS Cloudformationスタックから提供されたパラメータで別リージョンに別のAWS Cloudformationスタックをデプロイして結果を呼出元に返して、呼出元の処理で結果パラメータを使用してリソースを関連付けるということを主な目的としています。

この別リージョンにデプロイするAWS CloudformationスタックはAWS Lambdaの実行時間である最大15分以内に終了するようにする必要があります。もし、カスタムリソースを使用して15分以上かかるCloudformationスタックをデプロイするような場合はCloudformationテンプレートを複数に分けて実行するか、制限時間の無い別の方法で実行するのがよいでしょう。

また、このLambdaカスタムリソースにアタッチするIAMロールのIAMポリシー権限はLambda関数がアクセスするリソース、Cloudformationの実行、Cloudformationスタックでデプロイするリソースなど必要な権限を付与する必要があります。

別リージョンにAWS CloudformationスタックをデプロイするAWS Lambdaカスタムリソースは次のようにPythonで実装しました。使用したランタイムはPython 3.9です。
工夫したポイントがいくつかあるので特徴を挙げてみます。

  • カスタムリソース仕様のレスポンスを返すメソッドを自前で作成した
    CloudFormationスタックから呼び出す場合はcfnresponse.sendが使用できますが、Lambda単体での実行やテストには使用できないためcfn_response_sendという自前のメソッドを作成しました。
    参考:cfn-response モジュール - AWS CloudFormation
  • デプロイするテンプレートをS3またはハードコーディングから選択できるようにした
    S3からテンプレートを取得する方法では様々なテンプレートが実行できます。一方で特定のテンプレート実行に特化させたい場合はハードコーディングを選択することで他のテンプレートを実行させないようにできます。
  • カスタムリソースからデプロイするCloudFormationテンプレートの引数の指定を簡素化した
    デプロイするCloudformationテンプレートの引数として使用しないキーを指定して、それ以外を渡すことで引数の受け渡しを汎用的にしました。
  • 要求された処理と異なる状況の場合でもリソースが作成される方針に寄せた
    要求されたCreate、Update処理をそのままCloudFormationのデプロイに使用するのではなく、Update処理でスタックが存在しない場合にはCreate処理に切り替えるなど、なるべくリソースが作成される方針に寄せました。
  • 処理のスキップを選択できるようにした
    呼出元で依存関係を維持しながらCreate処理では使用せず、Update処理から使用するといったことができるようにスキップ処理が選択できるようにしました。

このようにかなり癖が強いAWS Lambdaカスタムリソースになっていますので、参考にする場合はその点について特にご注意ください。

# ## このカスタムリソースの特徴
# * カスタムリソース仕様のレスポンスを返すメソッドを自前で作成した
#   CloudFormationスタックから呼び出す場合はcfnresponse.sendが使用できますが、Lambda単体での実行やテストには使用できないためcfn_response_sendという自前のメソッドを作成しました。
#   参考:https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/cfn-lambda-function-code-cfnresponsemodule.html
# * デプロイするテンプレートをS3またはハードコーディングから選択できるようにした
#   S3からテンプレートを取得する方法では様々なテンプレートが実行できます。一方で特定のテンプレート実行に特化させたい場合はハードコーディングを選択することで他のテンプレートを実行させないようにできます。
# * カスタムリソースからデプロイするCloudFormationテンプレートの引数の指定を簡素化した
#   デプロイするCloudformationテンプレートの引数として使用しないキーを指定して、それ以外を渡すことで引数の受け渡しを汎用的にしました。
# * 要求された処理と異なる状況の場合でもリソースが作成される方針に寄せた
#   要求されたCreate、Update処理をそのままCloudFormationのデプロイに使用するのではなく、Update処理でスタックが存在しない場合にはCreate処理に切り替えるなど、なるべくリソースが作成される方針に寄せました。
# * 処理のスキップを選択できるようにした
#   呼出元で依存関係を維持しながらCreate処理では使用せず、Update処理から使用するといったことができるようにスキップ処理が選択できるようにしました。

import urllib3
import json
import boto3
import botocore
import datetime
# import cfnresponse

SUCCESS = 'SUCCESS'
FAILED = 'FAILED'

http = urllib3.PoolManager()

# 呼出元のCloudformationスタックから受けた引数のうち、デプロイするCloudformationスタックの引数として使用しないキー
config = {
    "ExcludeKeys": ["ServiceToken", "StackName", "CfnTplS3Bucket", "CfnTplS3Key", "IsSkip"]
}

# デプロイするCloudformationテンプレートを直接コードに記述したい場合はここに記述
fixed_cfn_template = '''
'''

# us-east-1リージョンにCloudformationスタックをデプロイするクライアント
cfn_client = boto3.client('cloudformation', region_name='us-east-1')
# Cloudformationテンプレートを取得するS3リソース
s3_resource = boto3.resource('s3')

# カスタムリソースの仕様でCloudFormationにレスポンスを返す独自メソッド
# CloudFormationスタックから呼び出す場合はcfnresponse.sendが使用できるが、Lambda単体での実行には使用できないため独自メソッドを使用する
def cfn_response_send(event, context, response_status, response_data, physical_resource_id=None, no_echo=False, reason=None):
    response_url = event['ResponseURL']
    print(response_url)

    response_body = {
        'Status': response_status,
        'Reason': reason or 'See the details in CloudWatch Log Stream: {}'.format(context.log_stream_name),
        'PhysicalResourceId': physical_resource_id or context.log_stream_name,
        'StackId': event['StackId'],
        'RequestId': event['RequestId'],
        'LogicalResourceId': event['LogicalResourceId'],
        'NoEcho': no_echo,
        'Data': response_data
    }

    json_response_body = json.dumps(response_body)

    print('Response body:')
    print(json_response_body)

    headers = {
        'content-type': '',
        'content-length': str(len(json_response_body))
    }

    try:
        response = http.request('PUT', response_url, headers=headers, body=json_response_body)
        print('Status code:', response.status)
    except Exception as e:
        print('cfn_response_send(..) failed executing http.request(..):', e)

def lambda_handler(event, context):
    response_data = {}

    try:
        print(('event:' + json.dumps(event, indent=2)))

        resource_properties = event['ResourceProperties']
        is_skip = resource_properties.get('IsSkip')

        # IsSkipにtrueが入ってきた場合は何も処理せず空のレスポンスを返す
        # 呼出元でカスタムリソース処理をスキップする場合に使用する
        if is_skip is not None and is_skip.strip().lower() == 'true':
            print('Skip All Process.')
            cfn_response_send(event, context, SUCCESS, response_data)
            # cfnresponse.send(event, context, cfnresponse.SUCCESS, response_data)
            return

        stack_name = resource_properties.get('StackName')

        exclude_keys = config['ExcludeKeys']

        cfn_params = []
        for key, val in resource_properties.items():
            if key not in exclude_keys:
                cfn_params.append({
                    'ParameterKey': key,
                    'ParameterValue': val
                })

        print('----------[Start]cfn_params:----------')
        print(cfn_params)
        print('----------[End]cfn_params:------------')

        cfn_template = fixed_cfn_template
        try:
            # fixed_cfn_template変数に直接テンプレートを記述していない場合は指定されたS3バケットのオブジェクトからテンプレートを取得する
            if cfn_template.strip() == '':
                cfn_tpl_s3_bucket = resource_properties['CfnTplS3Bucket']
                cfn_tpl_s3_key = resource_properties['CfnTplS3Key']
                bucket = s3_resource.Bucket(cfn_tpl_s3_bucket)
                obj = bucket.Object(cfn_tpl_s3_key).get()
                cfn_template = obj['Body'].read().decode('utf-8')
                print('----------[Start]cfn_template:----------')
                print(cfn_template)
                print('----------[End]cfn_template:------------')
        except Exception as e:
            print('lambda_handler(..) failed executing read s3 obj(..):', e)
            raise

        if event['RequestType'] == 'Delete':
            try:
                print('Delete Stacks')
                # Delete処理の場合は単純に削除する
                print('Run cfn_client.delete_stack.')
                response = cfn_client.delete_stack(
                    StackName=stack_name
                )

                print('cfn_client.delete_stack response:')
                print(response)

                # スタックのDelete処理が完了するまで待つ
                waiter = cfn_client.get_waiter('stack_delete_complete')
                waiter.wait(StackName=stack_name)
            except (botocore.exceptions.ValidationError, botocore.exceptions.ClientError) as err:
                err_msg = str(err)
                print('err_msg:')
                print(err_msg)
                #相互に関連付けしたリソース同士で削除エラーのループが発生するため、例外は上げない。

        if event['RequestType'] == 'Create' or event['RequestType'] == 'Update':
            # Create処理とUpdate処理の実行方針
            # Create処理要求かつスタックが存在しない ⇒ Create処理
            # Create処理要求かつスタックが存在する ⇒ 何もしない
            # Update処理要求かつスタックが存在しない ⇒ Create処理
            # Update処理要求かつスタックが存在するかつ変更点が存在する ⇒ Update処理
            # Update処理要求かつスタックが存在するかつ変更点が存在しない ⇒ 何もしない

            # 同名のスタックが存在するかを示すフラグ
            exist_stack = False

            try:
                print('Run cfn_client.describe_stacks.')
                existing_stacks = cfn_client.describe_stacks(StackName=stack_name)
                print('Existing Stacks:')
                print(existing_stacks)

                # スタックが存在し、describe_stacksが正常に処理されればexist_stackはTrue
                exist_stack = True
            except (botocore.exceptions.ValidationError, botocore.exceptions.ClientError) as err:
                err_msg = str(err)
                print('err_msg:')
                print(err_msg)
                # スタックが存在せず、describe_stacksがエラーになればexist_stackはFalse
                if 'DescribeStack' in err_msg and 'does not exist' in err_msg:
                    print(('describe_stacks error: ' + err_msg))
                    exist_stack = False
                else:
                    raise

            # Create処理が必要かを示すフラグ
            need_create = True

            if event['RequestType'] == 'Update':
                print('Update Stacks')
                if exist_stack == False:
                    # Update処理かつスタックが存在しない場合はCreate処理でスタックを作成する
                    need_create = True
                else:
                    # Create処理が必要かどうかを示すフラグ
                    need_create = False
                    # すでにスタック名に一致するスタックが存在すればUpdateで処理する
                    try:
                        print('Run cfn_client.update_stack.')
                        response = cfn_client.update_stack(
                            StackName=stack_name,
                            TemplateBody=cfn_template,
                            Parameters=cfn_params,
                            Capabilities=[
                                'CAPABILITY_NAMED_IAM'
                            ]
                        )

                        print('cfn_client.update_stack response:')
                        print(response)

                        # スタックのUpdate処理が完了するまで待つ
                        waiter = cfn_client.get_waiter('stack_update_complete')
                        waiter.wait(StackName=stack_name)
                    except (botocore.exceptions.ValidationError, botocore.exceptions.ClientError) as err:
                        err_msg = str(err)
                        print('err_msg:')
                        print(err_msg)
                        # Update処理を要求されたが、変更点がない場合は何もしない
                        if 'UpdateStack' in err_msg and 'No updates are to be performed' in err_msg:
                            print(('update_stacks error: ' + err_msg))
                        else:
                            raise

            if event['RequestType'] == 'Create' or need_create == True:
                print('Create Stacks')
                # Create処理要求かつスタックが存在しない場合、またはUpdate処理要求かつスタックが存在しない場合にスタックをCreate処理する
                if exist_stack == False:
                    print('Run cfn_client.create_stack.')
                    response = cfn_client.create_stack(
                        StackName=stack_name,
                        TemplateBody=cfn_template,
                        Parameters=cfn_params,
                        Capabilities=[
                            'CAPABILITY_NAMED_IAM'
                        ]
                    )

                    print('cfn_client.create_stack response:')
                    print(response)

                    # スタックのCreate処理が完了するまで待つ
                    waiter = cfn_client.get_waiter('stack_create_complete')
                    waiter.wait(StackName=stack_name)

            # CreateまたはUpdateされたスタックを取得する
            stacks = cfn_client.describe_stacks(StackName=stack_name)

            # Outputsの内容を取得し、返却値として整形する
            outputs = stacks['Stacks'][0]['Outputs']
            print(('Outputs:' + json.dumps(outputs, indent=2)))

            for output in outputs:
                response_data[output['OutputKey']] = output['OutputValue']
            print(('Outputs:' + json.dumps(response_data, indent=2)))
        # CloudFormationカスタムリソース仕様で呼出元のスタックにレスポンスを返す
        cfn_response_send(event, context, SUCCESS, response_data)
        # cfnresponse.send(event, context, cfnresponse.SUCCESS, response_data)

        return
    except Exception as e:
        print('Exception:')
        print(e)
        # CloudFormationカスタムリソース仕様で呼出元のスタックにレスポンスを返す
        cfn_response_send(event, context, FAILED, response_data)
        # cfnresponse.send(event, context, cfnresponse.FAILED, response_data)

        return

AWS Lambdaカスタムリソースに渡される呼出元Cloudformationスタックからの引数について

上記のカスタムリソースに渡される呼出元Cloudformationスタックからの引数は次のようなフォーマットを想定しています。

{
  "RequestType": "Create",
  "ResponseURL": "http://pre-signed-S3-url-for-response",
  "StackId": "arn:aws:cloudformation:ap-northeast-1:123456789012:stack/stack-name/guid",
  "RequestId": "unique id for this create request",
  "ResourceType": "Custom::TestResource",
  "LogicalResourceId": "MyTestResource",
  "ResourceProperties": {
    "ServiceToken": "arn:aws:lambda:ap-northeast-1:XXXXXXXXXXXX:function:CustomResourceToDeployCloudformationStack",
    "StackName": "CustomResourceToDeployCloudformationStackDemo",
    "CfnTplS3Bucket": "<デプロイするCloudformationテンプレートを格納するS3バケット名>",
    "CfnTplS3Key": "<デプロイするCloudformationテンプレートのS3バケット名以降のオブジェクトキー>",
    "IsSkip": "<Cloudformationテンプレートのデプロイ処理をスキップするかどうかを示すフラグ>",
    "<デプロイするスタックへ渡すキー1>": "<デプロイするスタックへ渡す値1>",
    "<デプロイするスタックへ渡すキー2>": "<デプロイするスタックへ渡す値2>",
      ~省略~
  }
}

このフォーマットのうちカスタムリソースを使用するために呼出元スタックで設定する必要があるのは次の引数です。

  • StackName
    カスタムリソースから別リージョンにデプロイするCloudformationスタックの名称です。
  • CfnTplS3Bucket
    カスタムリソースから別リージョンにデプロイするCloudformationテンプレートをS3に格納する場合のバケット名です。
  • CfnTplS3Key
    カスタムリソースから別リージョンにデプロイするCloudformationテンプレートをS3に格納する場合のバケット名以降のキーです。
  • IsSkip
    カスタムリソースから別リージョンへのCloudformationテンプレートデプロイ処理をスキップする場合に使用します。trueの場合は処理を実行せずスキップします。
  • <デプロイするスタックへ渡すキーX>
    カスタムリソースから別リージョンにデプロイするCloudformationテンプレートにわたす引数です。今回実装したカスタムリソースでは
    "ServiceToken","StackName","CfnTplS3Bucket","CfnTplS3Key","IsSkip"
    以外に渡されてきた引数はすべてデプロイするCloudformationテンプレートに引数として渡します。そのため、呼出元では余計な引数を渡さないように注意が必要です。

参考:
カスタムリソース - AWS CloudFormation
What is AWS CloudFormation? - AWS CloudFormation
Tech Blog with related articles referenced

まとめ

今回は主にus-east-1以外のリージョンのCloudFormationスタックから呼び出して、us-east-1にCloudFormationスタックをデプロイするLambdaカスタムリソースを作成してみました。
次回はこのカスタムリソースを使用して実際にリソースをデプロイする例を紹介したいと思います。

Written by Hidekazu Konishi
Hidekazu Konishi (小西秀和), a Japan AWS Top Engineer and a Japan AWS All Certifications Engineer

執筆者小西秀和

Japan AWS Top Engineer, Japan AWS All Certifications Engineer(AWS認定全冠)として、知識と実践的な経験を活かし、AWSの活用に取り組んでいます。
Amazon.co.jp: 小西 秀和: books, biography, latest update
NRIネットコムBlog: 小西 秀和: 記事一覧
Personal Tech Blog