Lambda-backed Custom Resourceのcfn-responseモジュールを利用する上での注意点

2017.01.19

はじめに

こんにちは、中山です。

軽くハマったのでエントリにまとめてご紹介したいと思います。ドキュメントちゃんと読めという話ではありますが。。。

CloudFormation(以下CFn)テンプレートを作成している際にLambda-backed Custom Resourceを利用している方は多いかと思います。CFnは組み込み関数が(恐らく設計思想上)あまり充実してないので(この辺りはTerraformと大きく異る部分)、何らかのプログラマブルな処理をさせたい場合に利用すると大変便利です。例えば、最新のAMI IDを取得したり、または単純な数値計算といった細かい処理などです。Lambda関数でできることは基本何でもできます。

大変便利な機能なので私はよく利用しているのですが、ある日この機能を利用しているスタックを削除しようとした時に、ステータスが長時間DELETE_IN_PROGRESSのままになっていることに気付きました。スタックのイベントを確認してみるとカスタムリソースがこの状態のままとなっており処理が進んでないようです。この状態が長時間続くので一向にスタックの削除が終わりません。処理が進んだかと思えばDELETE_FAILEDになっているという落ちです。

結論を先に書くと、 カスタムリソースからLambda関数へリクエストするタイプ(RequestType)がDeleteの時に、 send メソッドの引数に必要な値を渡していなかった ことが原因でした。この現象はCFnテンプレート中にLambda-backed Custom Resourceを定義してcfn-responseモジュールを利用した場合に当てはまります。LambdaのコードをS3上に置き、自分でレスポンスメソッドを定義している場合はその実装方法によって挙動が変わります。以下で詳しく解説します。

解説

Lambda-backed Custom Resourceの記述方法は2種類あります。CFnテンプレートに直接インラインで書く方法、S3バケット上にコードを含んだZipファイルを置いてそれを参照する方法です。それぞれメリット/デメリットがあるのですが、数十行程度のLambda関数であればインラインで書くことをオススメします。こちらのドキュメントに記載されているように、インラインの場合はcfn-responseモジュールが利用できるからです。該当の文言を引用します。

The cfn-response module is available only when you use the ZipFile property to write your source code. It isn't available for source code stored in S3 buckets. For code in S3 buckets, you must write your own functions to send responses.

このモジュールを利用するメリットは何処にあるのでしょうか。いくつかありますが、一番の利点は レスポンスメソッドを自分で実装しなくて済む という点です。Lambda-backed Custom Resourceはカスタムリソースからのリクエストタイプ(Create/Delete/Updateの3つ)によって、特定の形式に沿ってレスポンスを返却する必要があります。S3にコードを置いた場合はそれを自分で実装する必要があるのですが、cfn-responseモジュールを利用した場合はその部分をAWSにまるっとおまかせできます。cfn-responseモジュールで定義されているレスポンスメソッド( send メソッド )はドキュメントにソースコードが記載されています。Python/Node.js版がありますが、基本的に同じです。以下にPython版のソースコードを引用します。

#  Copyright 2016 Amazon Web Services, Inc. or its affiliates. All Rights Reserved.
#  This file is licensed to you under the AWS Customer Agreement (the "License").
#  You may not use this file except in compliance with the License.
#  A copy of the License is located at http://aws.amazon.com/agreement/ .
#  This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, express or implied.
#  See the License for the specific language governing permissions and limitations under the License.

from botocore.vendored import requests
import json

SUCCESS = "SUCCESS"
FAILED = "FAILED"

def send(event, context, responseStatus, responseData, physicalResourceId):
    responseUrl = event['ResponseURL']

    print responseUrl

    responseBody = {}
    responseBody['Status'] = responseStatus
    responseBody['Reason'] = 'See the details in CloudWatch Log Stream: ' + context.log_stream_name
    responseBody['PhysicalResourceId'] = physicalResourceId or context.log_stream_name
    responseBody['StackId'] = event['StackId']
    responseBody['RequestId'] = event['RequestId']
    responseBody['LogicalResourceId'] = event['LogicalResourceId']
    responseBody['Data'] = responseData

    json_responseBody = json.dumps(responseBody)

    print "Response body:\n" + json_responseBody

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

    try:
        response = requests.put(responseUrl,
                                data=json_responseBody,
                                headers=headers)
        print "Status code: " + response.reason
    except Exception as e:
        print "send(..) failed executing requests.put(..): " + str(e)

ハイライトした箇所に注目してください。このメソッドは5つの引数を取りますが、実際の所 PhysicalResourceId 以外の4つの引数を渡す必要があります(5つ目の PhysicalResourceId は指定しなくてもエラーにならないようです。ドキュメントと実際のコードが異なるか何らかの仕組みでデフォルト引数を渡している模様)。この内 responseData に注意しましょう。というのも、 Deleteリクエストでは responseData を返却する必要が無いにも関わらず、当然ですが引数に渡さないと必要な引数が足らないというエラーになる からです。

私は当初Deleteリクエスト時に responseData は返す必要が無いと思っていたので、特に指定せずに利用していました。Deleteリクエスト時に返却するレスポンスオブジェクトはこちらのドキュメントに記載されています。該当の部分を引用します。

When the delete request is successful, a response must be sent to the S3 bucket with the following fields:

Status

Must be "SUCCESS".

LogicalResourceId

The template developer-chosen name (logical ID) of the custom resource in the AWS CloudFormation template. This response value should be copied verbatim from the request.

RequestId

A unique ID for the request. This response value should be copied verbatim from the request.

StackId

The Amazon Resource Name (ARN) that identifies the stack that contains the custom resource. This response value should be copied verbatim from the request.

PhysicalResourceId

This value should be an identifier unique to the custom resource vendor, and can be up to 1 Kb in size. The value must be a non-empty string and must be identical for all responses for the same resource.

つまり、 仕様的にはレスポンスオブジェクトで responseData を返す必要はないが、cfn-responseモジュールのsendメソッドでは指定する必要がある ということです。ちょっとハマりました。。。まぁ、Lambda関数のログを見ればすぐに分かるのですが。。。

ちなみに、Deleteリクエスト用の処理を実装するかどうかは任意です。省略することもできます。ただし、その場合Lambda関数の全ての処理が常に実行されてしまいます。つまり、 本来不要な処理が実行される分リソースのステータスが変化するまで時間がかかってしまう という問題があります。スタックを削除する際は基本的にLambda関数の実行結果を取得する必要はないので(場合によりますが)、Deleteリクエストが来たらすぐにレスポンスでSUCCESSを返す処理を加えた方が良いです。

また、S3にコードを置き自分でレスポンスメソッドを実装する場合は、レスポンスオブジェクトの仕様に沿っていればよいので、こういった問題は(実装次第ですが)防ぐことが可能です。例えば、デフォルト引数を実装しておけば済む話です。

実際に挙動を確認してみる

本エントリで解説したかった内容は全部書ききったのですが、それだとちょっと寂しいので検証した結果を以下にご紹介したいと思います。

正しい例

まずは正しい例からご紹介します。以下のようなテンプレートを例にしてみます。パラメータで渡された数値に10を掛けてアウトプットにその結果を表示するという単純なものです。例えば4を入力したら40が出力されます。

---
AWSTemplateFormatVersion : 2010-09-09
Description: Test Template

Parameters:
  Number:
    Description: Input Number
    Type: Number

Resources:
  LambdaBasicExecRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Sid: LambdaBasicExecRole
            Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      Path: /
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
  LambdaMultiply:
    Type: AWS::Lambda::Function
    Properties:
      Code:
        ZipFile: |
          import cfnresponse
          def handler(event, context):
              if event['RequestType'] == 'Delete':
                  cfnresponse.send(event, context, cfnresponse.SUCCESS, {})
              number = event['ResourceProperties']['Number']
              response_data = {}
              response_data['MultipliedNumber'] = int(number) * 10
              cfnresponse.send(event, context, cfnresponse.SUCCESS, response_data)
      Handler: index.handler
      Role: !GetAtt LambdaBasicExecRole.Arn
      Runtime: python2.7
  CustomMultiply:
    Type: Custom::CustomMultiply
    Version: 1.0
    Properties:
      ServiceToken: !GetAtt LambdaMultiply.Arn
      Number: !Ref Number

Outputs:
  MultipliedNumber:
    Value: !GetAtt CustomMultiply.MultipliedNumber

ハイライトした箇所に注目してください。 send メソッドの4番目に空の辞書を渡しています。Deleteリクエストへのレスポンスオブジェクトには本来 responseData は返却する必要はないので、あくまで send メソッド用にオブジェクト(空の辞書)を渡しています。もちろん、空の辞書ではなく文字列でも何でも良いです。ただ、 responseData は(Pythonの場合)辞書で渡すのでその慣例に合わせているだけです。

このテンプレートからスタックを作成後、削除してみると分かりますが、ステータスが止まりことなく直ぐに削除できると思います。

正しくない例

続いて正しくない例です。以前私はこのような方法で実装していました。

---
AWSTemplateFormatVersion : 2010-09-09
Description: Test Template

Parameters:
  Number:
    Description: Input Number
    Type: Number

Resources:
  LambdaBasicExecRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Sid: LambdaBasicExecRole
            Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      Path: /
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
  LambdaMultiply:
    Type: AWS::Lambda::Function
    Properties:
      Code:
        ZipFile: |
          import cfnresponse
          def handler(event, context):
              if event['RequestType'] == 'Delete':
                  cfnresponse.send(event, context, cfnresponse.SUCCESS)
              number = event['ResourceProperties']['Number']
              response_data = {}
              response_data['MultipliedNumber'] = int(number) * 10
              cfnresponse.send(event, context, cfnresponse.SUCCESS, response_data)
      Handler: index.handler
      Role: !GetAtt LambdaBasicExecRole.Arn
      Runtime: python2.7
  CustomMultiply:
    Type: Custom::CustomMultiply
    Version: 1.0
    Properties:
      ServiceToken: !GetAtt LambdaMultiply.Arn
      Number: !Ref Number

Outputs:
  MultipliedNumber:
    Value: !GetAtt CustomMultiply.MultipliedNumber

このスタックを削除するとどうなるでしょうか。初めの方に書きましたが長時間DELETE_IN_PROGRESSのままになると思います。以下のような簡易的なシェルスクリプトで削除までどの程度時間が掛るか計測してみます。スタック削除からそれが完了するまでの時間を出力するという内容です。

  • time.sh
#!/usr/bin/env bash

stack_name="$1"

print_fmt() {
  local msg="$1"
  printf "$msg: $(date)\n"
}

aws cloudformation delete-stack \
  --stack-name "$stack_name"
print_fmt "DELETE_STARTED"

while true; do
  stack_status="$(aws cloudformation describe-stacks \
    --query 'Stacks[?StackName==`'$stack_name'`].StackStatus' \
    --output text)"
  [[ -n "$stack_status" ]] && print_fmt "$stack_status"
  [[ -z "$stack_status" || "$stack_status" == "DELETE_FAILED" ]] && break
done

print_fmt "DELETE_ENDED"

シェルスクリプトを実行してみると状態が遷移するまで大体1時間程度かかっているようですね。

$ ./time.sh test
DELETE_STARTED: Tue Jan  3 18:14:06 JST 2017
DELETE_IN_PROGRESS: Tue Jan  3 18:14:07 JST 2017
DELETE_IN_PROGRESS: Tue Jan  3 18:14:08 JST 2017
DELETE_IN_PROGRESS: Tue Jan  3 18:14:09 JST 2017
<snip>
DELETE_IN_PROGRESS: Tue Jan  3 19:14:46 JST 2017
DELETE_FAILED: Tue Jan  3 19:14:47 JST 2017
DELETE_ENDED: Tue Jan  3 19:14:47 JST 2017

スタックの状態がDELETE_FAILEDとなった原因をマネジメントコンソールで見ると、カスタムリソースの削除に「Custom Resource failed to stabilize in expected time」という理由で失敗していることが確認できます。

image1

Lambda関数の実行結果をCloudWatchで確認すると、以下のように「 send メソッドは引数を少なくとも4つ取るのに3つしか与えられてない」というエラーメッセージが表示されていました。

send() takes at least 4 arguments (3 given): TypeError

Traceback (most recent call last):

File "/var/task/index.py", line 4, in handler

cfnresponse.send(event, context, cfnresponse.SUCCESS)

TypeError: send() takes at least 4 arguments (3 given)

ちなみに、スタックの削除に失敗後、再度削除するとすぐに消せます。

まとめ

いかがだったでしょうか。

Lambda-backed Custom Resourceのcfn-responseモジュールを利用する上で注意する点についてご紹介しました。私がご紹介したかった内容をまとめると以下の2点です。この機能を利用する際に頭の片隅にでも置いていただければと思います。

  • Deleteリクエストが発生した際はすぐにSUCCESSを返す処理を入れましょう
  • cfn-responseモジュールを利用する際は send メソッドに渡す引数に注意しましょう

本エントリがみなさんの参考になれば幸いに思います。