Lambda-backedカスタムリソースではリクエストタイプごとの処理を意識しよう

Lambda-backedカスタムリソースを利用する際にはリクエストタイプを意識したコードが必須です。リクエストタイプごとの処理を記述しないとスタックがいつまで経っても作成完了にならない、削除完了にならないなどが起こるため使い始める前に理解しておきましょう。
2021.08.20

最近Lambda-backedカスタムリソースを使い始めたんですが、Lambdaに記述するリクエストタイプが分からず時間を浪費してしまったのでお勉強したことをまとめておきます。

カスタムリソースは非常に便利ですが、テンプレート外でいろいろできてしまうため諸刃の剣とも言われます。CloudFormationでサポートされていないものを補うために利用したいという方はしっかりと理解した上で使っていきましょう。

カスタムリソースとは

CloudFormationからLambdaやSNSを動かし、その結果を返すことで作成完了とするリソースのことです。ちょっと分かりにくいのでイメージ図。

上の図ではCloudFormationからスタックの作成が行われると、Lambdaとそれに必要なロールを作成します。その後カスタムリソースと呼ばれるリソースがLambdaを実行して、その結果をカスタムリソースへ応答オブジェクトとして返却します。

カスタムリソースは実体のないリソースで、テンプレート上でLambdaの実行と結果の取得を定義するためのリソースなので、AWS上には何も作成されない点がちょっと分かりにくいポイントですね。

Lambdaからのレスポンスとなる応答オブジェクトは、JSONの形式で送信されLambdaの成功/失敗や失敗時の理由、CloudFormationテンプレートに返したい任意のデータなどが含まれます。含まれる内容はドキュメントをご参照ください。

カスタムリソースの応答オブジェクト - AWS CloudFormation

Lambdaから応答オブジェクトに組み込んだ値はFn::GetAtt関数を使いテンプレート内で取得できます。この辺りはチュートリアルで一度使った方が分かりやすいです。

このように、CloudFormation上からプロバイダ(Lambda,SNS)を実行し、応答オブジェクトを取得するリソースのことをカスタムリソースと言います。

リクエストタイプとは

カスタムリソースを利用する際に覚えておきたいのがリクエストタイプです。CloudFormationからカスタムリソースに対して作成・更新・削除したときにLambdaへ送信されるリクエストに含まれるリクエストタイプが変わります。

上記の例では、Lambdaは最新のAMI IDを取得してカスタムリソースへ返す処理を行うものだとします。CloudFormationからスタックの作成(Create)やカスタムリソースの更新(Update)が行われたときには、Lambdaは最新のAMI IDを取得して返すという動きをします。しかしスタックの削除(Delete)の時は何もせずにLambdaの成功をレスポンスして完了とするように動きが変わっています。

このようにCloudFormationスタックへの作成、更新、削除によってLambdaへ送られるリクエストタイプが変わることに注意しましょう。

カスタムリソースのリクエストタイプ - AWS CloudFormation

そのためLambda側ではeventのリクエストタイプに合わせ、どのような処理をしてレスポンスを返すのかを記述しておく必要があります。これを記述しないと、Lambdaの処理が終わったとしてもカスタムリソース側にレスポンスが返らず、スタックのタイムアウト(デフォルトは1時間)になるまで作成や削除が完了しなくなるので気をつけましょう…

カスタムリソースを使ってスタックを作成、削除するときにCREATE_IN_PROGRESSDELETE_IN_PROGRESSの状態から進まない場合があります。そんなときはリクエストタイプごとにレスポンスを返すようLambdaのコードが書けているかを確認してみましょう。

あくまで一例ですが、私はハンドラーに以下のように記述するようにしています。この他にエラーが発生した場合にもレスポンスを返す必要があるので漏れがないようにしましょう。

import cfnresponse

def lambda_handler(event, context):
    if event['RequestType'] == 'Create':
        # Create時に実行したい処理
        cfnresponse.send(event, context, cfnresponse.SUCCESS,
                            {'Response': 'Success'})
    if event['RequestType'] == 'Delete':
        # Delete時に実行したい処理
        cfnresponse.send(event, context, cfnresponse.SUCCESS,
                            {'Response': 'Success'})
    if event['RequestType'] == 'Update':
        # Update時に実行したい処理
        cfnresponse.send(event, context, cfnresponse.SUCCESS,
                            {'Response': 'Success'})

cfnresponseモジュールの使い方についてはドキュメントに記載あります。

cfn-response モジュール

以下のエントリも合わせて確認しておくとハマらずに済みます。

リクエストタイプの更新(Update)には注意

リクエストタイプの更新はスタックの更新ではなく「カスタムリソースのプロパティに変更があった場合」カスタムリソースのプロバイダにリクエストが送信されることに注意してください

Lambdaのコードを変更してスタックを更新したとしても、カスタムリソースのプロパティが変更されていなければリクエストは送信されずにLambdaは実行されません。

更新 - AWS CloudFormation

カスタムリソースを試してみる

簡単なテンプレートを使ってスタックを作成、更新、削除の動作を確認してみます。

テンプレートの確認

以下のようなサンプルのテンプレートを使ってカスタムリソースを試してみます。

AWSTemplateFormatVersion: "2010-09-09"

Resources:
  SampleLambda:
    Type: Custom::SampleLambda
    Properties:
      ServiceToken: !GetAtt "LambdaFunction.Arn"
      Hoge: "hoge"

  LambdaFunction:
    Type: AWS::Lambda::Function
    Properties:
      Role: !GetAtt "LambdaExecutionRole.Arn"
      Runtime: "python3.8"
      Handler: index.lambda_handler
      Timeout: "180"
      Code:
        ZipFile: |
          import cfnresponse

          def lambda_handler(event, context):
              hoge = event['ResourceProperties']['Hoge']
              if event['RequestType'] == 'Create':
                  print('Create:'+hoge)
                  cfnresponse.send(event, context, cfnresponse.SUCCESS,
                                      {'Response': 'Success'})
              if event['RequestType'] == 'Delete':
                  print('Delete:'+hoge)
                  cfnresponse.send(event, context, cfnresponse.SUCCESS,
                                      {'Response': 'Success'})
              if event['RequestType'] == 'Update':
                  print('Update:'+hoge)
                  cfnresponse.send(event, context, cfnresponse.SUCCESS,
                                      {'Response': 'Success'})

  LambdaExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - lambda.amazonaws.com
            Action:
              - sts:AssumeRole
      Path: /
      Policies:
        - PolicyName: sample-lambda-policy
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - logs:CreateLogGroup
                  - logs:CreateLogStream
                  - logs:PutLogEvents
                Resource: arn:aws:logs:*:*:*

SampleLambdaがカスタムリソースでLambdaFunctionを呼び出しています。プロパティの中でHoge: 'hoge'という値をリクエストに含めるよう定義しています。

LambdaFunctionでは簡単なコードを実装していて、カスタムリソースから受け取ったHogeの値を取得して、リクエストタイプ+Hogeを出力します。

LambdaExecutionRoleはLambdaにアタッチするロールを定義しています。今回はログを出力するだけなので、最低限の権限のみ付与しています。

スタックの作成

先ほどのテンプレートを使ってスタックを作成してみます。特に特別な設定は必要ないのでデフォルトで作成してみましょう。

ロール、Lambda関数、カスタムリソースの順で作成されているのが確認できます。

Lambdaの実行を確認するため、CloudWatch Logsを確認してみるとリクエストタイプCreateにカスタムリソースから引き渡されたhogeの値が出力されました。

スタックの更新

次はスタックの更新を確認してみます。

先ほど注意事項に書いた通りスタックの更新ではLambdaは実行されず、カスタムリソースの更新によって起動されることを確認してみましょう。

Lambdaのコードをハイライトの部分だけ変えて更新してみます。

import cfnresponse

def lambda_handler(event, context):
    hoge = event['ResourceProperties']['Hoge']
    if event['RequestType'] == 'Create':
        print('Create:'+hoge)
        cfnresponse.send(event, context, cfnresponse.SUCCESS,
                            {'Response': 'Success'})
    if event['RequestType'] == 'Delete':
        print('Delete:'+hoge)
        cfnresponse.send(event, context, cfnresponse.SUCCESS,
                            {'Response': 'Success'})
    if event['RequestType'] == 'Update':
        print('Update!!:'+hoge)
        cfnresponse.send(event, context, cfnresponse.SUCCESS,
                            {'Response': 'Success'})

無事LambdaFunctionは更新されました。

しかし、Lambdaの実行は行われないためメトリクスを確認してもスタック作成時の1回のみで、ログにも更新時のものはありません。

Lambdaのコード変更ではLambdaが実行されないことを確認したので、次にカスタムリソースの変更時の動作を確認してみましょう。

カスタムリソースを定義しているセクションでHogeの値をhugaに変更しました。

SampleLambda:
    Type: Custom::SampleLambda
    Properties:
      ServiceToken: !GetAtt "LambdaFunction.Arn"
      Hoge: "huga"

この状態でスタックの更新をしてみます。カスタムリソースであるSampleLambdaの更新が確認できました。

ログを確認してみると、リクエストタイプUpdateにカスタムリソースから新しく引き渡されたhugaの値が出力されました。

スタックの削除

最後にスタックの削除を確認しましょう。特に注意する点はないため、削除のリクエストタイプがリクエストとして送られた時に、ログに出力されるか確認します。

スタックの削除が開始されると、SampleLambdaがDELETE_IN_PROGRESSとなり、このタイミングでLambdaが実行されます。ログを確認するとカスタムリソースが削除される前に、リクエストタイプDeleteでカスタムリソースから引き渡されたhugaの値が出力されています。

この時cfnresponse.sendを使ったレスポンスを返さないと、カスタムリソースがDELETE_IN_PROGRESSから進まなくなるということは忘れないようにしましょう。

まとめ

Lambda-backedカスタムリソースを使う時のリクエストタイプについて解説しました。初めてカスタムリソースを使う時にハマりがちなポイントなので、これから使う方は意識してみてください。この記事で一人でもCREATE_IN_PROGRESSDELETE_IN_PROGRESSで1時間待ちぼうけになる人が減れば幸いです。