cfn-response モジュールをさらに活用してみる

この記事は公開されてから半年以上経過しています。情報が古い可能性がありますので、ご注意ください。

概要

CloudFormation で Lambda-backed カスタムリソースを作成する際に利用する cfn-response モジュールですが、実体は以下ソースコードになっています。
cfn-response モジュール – モジュールのソースコード

send メソッドに渡す引数のうち必須なのは event, context, responseStatus, responseData の4つなので、この4つを利用しているテンプレートが多いと思いますが、

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

私が最近重宝しているのは、オプション引数の physicalResourceId と reason です。
本記事では主にこちらの2つについてお話したいと思います。

physicalResourceId

CloudFormation コンソール上では「物理 ID」と表示される項目です。
特に指定しない場合は以下のように CloudWatch Logs のログストリーム名になります。

reason

こちらは「状況の理由」欄に表示されるメッセージです。
特に指定しない場合は以下のように定型的なメッセージ + ログストリーム名になります。

活用例

以下のようなテンプレートを作ってみました。
こちらは以前投稿した Session Manager の設定を行うテンプレートに少し手を加えたものです。

AWSTemplateFormatVersion: "2010-09-09"

Parameters:
  IdleSessionTimeout:
    Type: Number
    Default: 30

Resources:
  SsmDocument:
    Type: Custom::SsmDocument
    Properties:
      ServiceToken: !GetAtt Function.Arn
      DocumentParams:
        Name: SSM-SessionManagerRunShell
        DocumentType: Session
        DocumentFormat: JSON
        Content: !Sub |
          {
            "schemaVersion": "1.0",
            "inputs": {
              "cloudWatchEncryptionEnabled": false,
              "s3BucketName": "",
              "s3KeyPrefix": "",
              "s3EncryptionEnabled": false,
              "runAsDefaultUser": "",
              "cloudWatchStreamingEnabled": false,
              "kmsKeyId": "",
              "runAsEnabled": false,
              "idleSessionTimeout": "${IdleSessionTimeout}",
              "shellProfile": {
                "linux": "",
                "windows": ""
              },
              "cloudWatchLogGroupName": ""
            },
            "description": "Document to hold regional settings for Session Manager",
            "sessionType": "Standard_Stream"
          }

  Function:
    Type: AWS::Lambda::Function
    DependsOn: FunctionLog
    Properties:
      FunctionName: !Join
        - ''
        - - ssm-document-custom-function-
          - !Select [ '0', !Split [ '-', !Select [ '2', !Split [ '/', !Ref AWS::StackId ] ] ] ]
      Code:
        ZipFile: |
          import logging
          import os
          import traceback

          import boto3
          import cfnresponse
          from botocore.exceptions import ClientError

          logger = logging.getLogger()
          logger.setLevel(os.environ['LOG_LEVEL'])

          ssm = boto3.client('ssm')

          def lambda_handler(event, context):
              try:
                  document_params = event['ResourceProperties']['DocumentParams']
                  account_id = event['StackId'].split(':')[4]
                  document_arn = f'arn:aws:ssm:{os.environ["AWS_REGION"]}:{account_id}:document/{document_params["Name"]}'

                  if event['RequestType'] in ['Create', 'Update']:
                      if document_exists(document_params):
                          update_document(document_params)
                      else:
                          create_document(document_params)

                  cfnresponse.send(event, context, cfnresponse.SUCCESS, {}, physicalResourceId=document_arn)
              except:
                  tb = traceback.format_exc()
                  logger.error(tb)
                  cfnresponse.send(event, context, cfnresponse.FAILED, {}, physicalResourceId=None, noEcho=False, reason=tb.replace('\n', r'\n'))

          def document_exists(document_params:dict) -> bool:
              document_identifiers = ssm.list_documents(
                  Filters=[
                      {
                          'Key': 'Name',
                          'Values': [ document_params['Name'] ]
                      },
                      {
                          'Key': 'Owner',
                          'Values': [ 'Self' ]
                      },
                      {
                          'Key': 'DocumentType',
                          'Values': [ document_params['DocumentType'] ]
                      },
                  ],
              )['DocumentIdentifiers']

              if len(document_identifiers) == 0:
                  return False
              elif len(document_identifiers) == 1:
                  return True

          def create_document(document_params:dict) -> None:
              response = ssm.create_document(
                  Content=document_params['Content'],
                  Name=document_params['Name'],
                  DocumentType=document_params['DocumentType'],
                  DocumentFormat=document_params['DocumentFormat'],
              )
              logger.info(response)

          def update_document(document_params:dict) -> None:
              try:
                  updated_version = ssm.update_document(
                      Content=document_params['Content'],
                      Name=document_params['Name'],
                      DocumentFormat=document_params['DocumentFormat'],
                      DocumentVersion='$LATEST',
                  )['DocumentDescription']['DocumentVersion']
              except ClientError as e:
                  if e.response['Error']['Code'] == 'DuplicateDocumentContent':
                      logger.info('no update to the SSM document')
                      return None
                  else:
                      raise e
              response = ssm.update_document_default_version(
                  Name=document_params['Name'],
                  DocumentVersion=updated_version
              )
              logger.info(response)
      Environment:
        Variables:
          LOG_LEVEL: INFO
      Handler: index.lambda_handler
      MemorySize: 128
      Role: !GetAtt FunctionRole.Arn
      Runtime: python3.8
      Timeout: 120

  FunctionRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Join
        - ''
        - - ssm-document-custom-function-role-
          - !Select [ '0', !Split [ '-', !Select [ '2', !Split [ '/', !Ref AWS::StackId ] ] ] ]
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
        - Effect: Allow
          Principal:
            Service:
            - lambda.amazonaws.com
          Action:
          - sts:AssumeRole
      Policies:
        - PolicyName: ssm-document-policy
          PolicyDocument:
            Version: 2012-10-17
            Statement:
              - Effect: Allow
                Action:
                  - ssm:ListDocuments
                Resource: "*"
              - Effect: Allow
                Action:
                  - ssm:UpdateDocument
                  - ssm:CreateDocument
                  - ssm:UpdateDocumentDefaultVersion
                Resource: !Sub arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:document/SSM-SessionManagerRunShell
        - PolicyName: lambda-logs-policy
          PolicyDocument:
            Version: 2012-10-17
            Statement:
              - Effect: Allow
                Action:
                  - logs:CreateLogGroup
                Resource: !Sub arn:aws:logs:${AWS::Region}:${AWS::AccountId}:*
              - Effect: Allow
                Action:
                  - logs:CreateLogStream
                  - logs:PutLogEvents
                Resource: !Join
                  - ''
                  - - !Sub arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/ssm-document-custom-function-
                    - !Select [ '0', !Split [ '-', !Select [ '2', !Split [ '/', !Ref AWS::StackId ] ] ] ]
                    - :*

  FunctionLog:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Join
        - ''
        - - /aws/lambda/ssm-document-custom-function-
          - !Select [ '0', !Split [ '-', !Select [ '2', !Split [ '/', !Ref AWS::StackId ] ] ] ]
      RetentionInDays: 7

本テンプレートを利用しスタックを作成すると、リソースの欄は以下のようになります。

ログストリーム名ではなく、任意の値を物理IDとして表示することが可能となっています。
何のリソースを作成したのか、ログストリーム名より分かりやすくなっていると思います。
カスタムリソースのソースコードでいうと、以下の黄文字の部分で物理IDを指定しています。

def lambda_handler(event, context):
    try:
        document_params = event['ResourceProperties']['DocumentParams']
        account_id = event['StackId'].split(':')[4]
        document_arn = f'arn:aws:ssm:{os.environ["AWS_REGION"]}:{account_id}:document/{document_params["Name"]}'

        if event['RequestType'] in ['Create', 'Update']:
            if document_exists(document_params):
                update_document(document_params)
            else:
                create_document(document_params)

        cfnresponse.send(event, context, cfnresponse.SUCCESS, {}, physicalResourceId=document_arn)

次は、本テンプレートを利用して意図的にエラーを発生させてみようと思います。
CFn パラメータの「IdleSessionTimeout」を61以上の数値に変更してみます。
すると、以下のように CFn コンソール上にエラーメッセージが表示されました。

カスタムリソースのソースコードでいうと、以下の部分でエラーメッセージ(Python のスタックトレース)を取得し、送信しています。

    except:
        tb = traceback.format_exc()
        logger.error(tb)
        cfnresponse.send(event, context, cfnresponse.FAILED, {}, physicalResourceId=None, noEcho=False, reason=tb.replace('\n', r'\n'))

このやり方には2つの利点があると考えています。

  1. カスタムリソースの実行ログ (CloudWatch Logs) を見に行かなくてもエラー内容が分かる
  2. CFn スタック初回作成時にカスタムリソース実行が失敗してもエラー内容を残しておける

2点目は、CFn テンプレート内でカスタムリソース用Function のロググループを作成している場合の話です。
スタック初回作成時にカスタムリソースの作成が失敗するとロールバックにより全リソースが削除されるため、カスタムリソース用Function のロググループまで消えてしまいます。
本テンプレートのように cfn-response モジュール でエラーメッセージを送信しておくことで、ロググループが消えても CFn コンソールにそのエラーメッセージを残しておけるというわけです。

おまけ

本記事で紹介した CFn テンプレートで使っているワザなのですが、
!Select [ '0', !Split [ '-', !Select [ '2', !Split [ '/', !Ref AWS::StackId ] ] ] ]
と書いてあげると、以下のような StackId の中の 1234abcd みたいな文字列が取得できるので、パスワードではないランダム文字列を生成するのに便利です。

arn:aws:cloudformation:region:111122223333:stack/stack-name/1234abcd-56ef-78gh-90ij-123456klmnop

例えば以下のように使うと、ssm-document-custom-function-1234abcd のようなリソース名になります。

  Function:
    Type: AWS::Lambda::Function
    DependsOn: FunctionLog
    Properties:
      FunctionName: !Join
        - ''
        - - ssm-document-custom-function-
          - !Select [ '0', !Split [ '-', !Select [ '2', !Split [ '/', !Ref AWS::StackId ] ] ] ]